Model purchase access with a TypeScript discriminated union
Replace conflicting checkout booleans with explicit access states, required payloads, and an exhaustive switch checked with TypeScript 5.9.3.
A download button should not need to interpret six unrelated booleans. isLoading, hasPurchase, hasError, and canDownload can describe contradictory combinations unless every caller remembers the same unwritten rules.
A discriminated union makes the view choose one state and carry the data that belongs to it. The small model below is for presentation after a server decision, not for proving that somebody paid. Its compiler fixtures were checked with TypeScript 5.9.3 under strict checking on September 9, 2026.
Put the payload beside the state that requires it
Every variant has a literal kind field. Only available carries downloadUrl; checking cannot promise a URL it does not have. recoverable carries a message because that state needs an explanation and a recovery action. unavailable means the view has no usable access result, not necessarily that the person has never purchased.
TypeScript narrows a discriminated union when code checks the shared literal property. Keeping the payload inside its variant removes the need to assert that an unrelated optional field must exist. It also gives the next engineer a finite set of cases to inspect.
Make the action label exhaustive
The function below deliberately handles each variant. The never assignment makes an unhandled variant a compiler error. If an expired state is added later, this function must be updated instead of quietly falling through to a generic purchase button.
Use the same state as the input to the button label, disabled state, and recovery affordance. Do not reintroduce independent flags in the template. A recoverable failure should lead to recovery; it should not accidentally encourage someone to pay a second time.
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.
Do not confuse a type with validation or authorization
JSON received over HTTP is not trustworthy just because it is assigned a TypeScript type. Validate the response before constructing AccessState, and let the server remain responsible for access. Casting a query parameter to the available variant is not a purchase check.
For example, map a verified server response to available only after validating its expected fields and the destination URL policy. Map a temporary recovery failure to recoverable. Keep provider errors, internal identifiers, and deployment instructions out of the user-facing message.
SourcesTypeScript: type assertions have no runtime effect (opens a new tab)
A union describes states, not every allowed transition
This model does not prevent old asynchronous work from changing the current state. Nor does it define which transitions are legal. A larger workflow may need a reducer, an explicit transition table, or a state-machine library. Start with the smallest model that captures the real behavior and add transition rules when tests expose a need.
The payoff is concrete: an available state must carry its payload, new variants require deliberate handling, and a recovery failure cannot masquerade as a completed download. The type makes the presentation contract clearer without claiming to replace the backend.