Skip to content
R.

Model purchase access with a TypeScript discriminated union

Use an explicit TypeScript access state to prevent contradictory download UI, then validate the server response before constructing it.

About 8 min readComments

A purchase screen can say something impossible: loading has finished, an error is visible, and the download button still expects a missing URL. The problem is not the button styling. Independent booleans let the view describe combinations nobody intended.

This walkthrough is for a TypeScript developer building a recovery or download interface. Replace those combinations with four named presentation states, check every action label exhaustively, and construct the state from a validated response. None of that proves payment: authorization remains on the server.

Make the state visible before designing the type

The four panels are alternatives for the same download area, not a sequence a buyer must complete. Checking offers no download yet. Available has the URL the action needs. Recoverable offers a recovery path. Unavailable means no usable access result, which must not be presented as proof that this person never purchased.

Each action label below comes from the same actionLabel function used in the code example. The surrounding copy is an illustrative presentation, not a shipped checkout or a usability study. This makes the type's purpose concrete: downstream UI should receive one coherent state rather than reconstruct intent from several flags.

Four separate download panels show checking access, open download, recover access, and view purchase options, each with copy appropriate to its state.
Controlled presentation fixture. Action labels are produced by the TypeScript example; the four variants are alternatives, not a payment flow. No purchase or access grant occurs.

Put required data inside the variant

Every variant has the literal kind field. Only available exposes downloadUrl. recoverable carries application-owned copy. That relationship is more useful than making downloadUrl optional on every state and hoping each caller checks the right flags first.

The union does not decide which transition is legal. It describes the shape of a value after a decision has been made. A response adapter or reducer still needs to decide when the UI enters checking, available, recoverable, or unavailable. Keeping that distinction prevents a useful type from being mistaken for a complete state machine.

The view contract
StateRequired dataPrimary action
checkingNoneWait for the server response
availableA validated download URLOpen the download
recoverableA public-facing messageRetry or recover access
unavailableNoneShow purchase options without asserting purchase history

SourcesTypeScript: discriminated unions (opens a new tab)

Let a new state break the incomplete consumer

An exhaustive switch is a maintenance check. Add an expired variant later and the never assignment will fail until this label function handles it. That is preferable to falling through to a default purchase label that could encourage a returning buyer to pay twice.

Use the discriminant to choose the action and its required data together. The label function alone does not disable a checking button, attach a click handler, or validate a URL. Those consumers should narrow the same state, not recreate independent booleans beside it.

access-state.ts / TypeScript
export type AccessState =
  | { kind: 'checking' }
  | { kind: 'available'; downloadUrl: string }
  | { kind: 'recoverable'; message: string }
  | { kind: 'unavailable' };

export function actionLabel(state: AccessState): string {
  switch (state.kind) {
    case 'checking': return 'Checking access';
    case 'available': return 'Open download';
    case 'recoverable': return 'Recover access';
    case 'unavailable': return 'View purchase options';
    default: {
      const unhandled: never = state;
      throw new Error(`Unhandled access state: ${unhandled}`);
    }
  }
}

SourcesTypeScript: exhaustiveness checking (opens a new tab)

Keep negative examples in the test suite

The compiler fixture includes three intentionally invalid cases: available without a URL, checking with an available-only payload, and reading downloadUrl without narrowing. Each uses an expected compiler error. If the model becomes so permissive that the invalid example compiles, the unused expectation fails the check.

A runtime test also checks all four labels. Those tests protect different contracts. The compiler fixtures protect which states code may construct; the runtime test protects the actual words a reader sees. A runtime test passing does not show that the invalid TypeScript constructions were rejected.

An expected-error comment is useful only when a compiler actually checks that file. Include these fixtures in a strict TypeScript test program, not only in a transpile-only build. The local test harness invokes TypeScript 5.9.3 and fails on any unexpected diagnostic, including an expectation whose error disappears.

access-state.type-test.ts
access-state.type-test.ts / TypeScript
import { AccessState, actionLabel } from './access-state';

actionLabel({ kind: 'available', downloadUrl: 'https://example.test/download' });
// @ts-expect-error Available access requires the URL in the same variant.
const missingUrl: AccessState = { kind: 'available' };
// @ts-expect-error A checking state does not promise a download URL.
const wrongState: AccessState = { kind: 'checking', downloadUrl: 'https://example.test/download' };
function readWithoutNarrowing(state: AccessState): string {
  // @ts-expect-error Narrow the variant before reading its payload.
  return state.downloadUrl;
}
void missingUrl;
void wrongState;
void readWithoutNarrowing;

A successful compile cannot validate a server payload

Start with unknown at the network boundary. An assertion such as payload as AccessState changes the compiler's view of the value; it does not inspect the value at runtime. A missing URL or unexpected state can still reach the page if this boundary is skipped.

The adapter below recognizes only supported states and strips unneeded fields. It also applies a narrow destination policy for a hypothetical same-origin /downloads/ endpoint. This portfolio uses a different delivery integration; copy the validation approach, not an endpoint policy that does not fit your application.

SourcesTypeScript: type assertions have no runtime effect (opens a new tab)

Construct the union at one response boundary

Give the rest of the UI an AccessState, not an unexamined provider response. This adapter accepts unknown, recognizes the supported response states, and returns only the fields the view needs. Unknown or malformed payloads become unavailable. A retry response gets application-owned copy, so an internal error message is not echoed into the page.

The example assumes that downloads are served through a same-origin /downloads/ endpoint. The configured origin comes from the application, never the response. URL parsing resolves relative paths and lets the adapter reject external destinations and unexpected paths. This is a destination policy for a hypothetical app, not the Google Drive policy used by this portfolio.

An allowed URL is still not permission to read a file. The download endpoint must verify access on every request. This adapter is for the response of an authenticated server check; passing arbitrary JSON through it does not authenticate the sender or prove payment. A different delivery system needs its own explicit destination policy.

access-response.ts / TypeScript
import type { AccessState } from './access-state';

// Only use after an authenticated request to the application's access endpoint.
export function accessFromResponse(input: unknown, origin: string): AccessState {
  if (!input || typeof input !== 'object') return { kind: 'unavailable' };
  const value = input as Record<string, unknown>;
  if (value.status === 'available' && typeof value.downloadUrl === 'string') {
    try {
      const url = new URL(value.downloadUrl, origin);
      if (url.origin === origin && url.pathname.startsWith('/downloads/') &&
          !url.username && !url.password) {
        return { kind: 'available', downloadUrl: url.href };
      }
    } catch { /* An invalid URL is not a usable download. */ }
    return { kind: 'unavailable' };
  }
  if (value.status === 'retry') {
    return { kind: 'recoverable', message: 'Please try checking access again.' };
  }
  return { kind: 'unavailable' };
}

Test malformed responses separately from type errors

The runtime cases exercise missing payloads, a script URL, a protocol-relative external URL, and an unrelated same-origin path. All map to unavailable. A relative /downloads/one URL maps to available with a resolved absolute URL. A retry payload containing a private message produces only the application-owned recovery message.

These runtime checks complement the compiler fixtures; neither replaces the other. The type fixture catches an internal caller building available without a URL. The adapter test catches a remote response that does not honor the declared contract. Keep a network-level test too, because neither unit suite establishes that the access endpoint is authentic or correctly authorized.

Compile all three files with TypeScript 5.9.3 installed / Shell
npx tsc --strict --noEmit --skipLibCheck --target es2022 --module esnext --moduleResolution bundler access-state.ts access-state.type-test.ts access-response.ts

Keep shape, transition, and permission separate

The union checks relationships among fields in code. It does not stop a client from constructing available, and it does not authenticate a remote response. The server must enforce access on every download request, including after the UI has rendered a link.

TypeScript is structurally typed: a previously assigned object may contain extra properties. Negative fresh-object fixtures are useful, but they are not a runtime exact-shape guarantee. Combine strict compilation, explicit response parsing, and server authorization instead of asking any one of them to do all three jobs.

Before shipping, test an expired link and a network failure in the actual screen. Confirm that recovery stays available without promising a download prematurely or treating a temporary failure as proof of non-purchase. The examples here do not implement those application transitions.

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed