// LOADING
// LOADING
// LOADING_ARTICLE
If you have used TypeScript for more than a few months, you have probably hit a bug caused by a type like { data: User | null; loading: boolean; error: string | null }. All three fields can be truthy at the same time. What does loading: true, data: someUser, error: 'timeout' even mean? This is the problem discriminated unions solve.
A discriminated union is a union type where every member has a shared field — the discriminant — with a unique literal type. TypeScript uses the discriminant to narrow the type inside a conditional:
tstype LoadingState = { status: 'loading' }; type SuccessState<T> = { status: 'success'; data: T }; type ErrorState = { status: 'error'; message: string }; type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;
Now model the same remote data fetch that caused bugs before:
tsfunction renderUser(state: AsyncState<User>) { switch (state.status) { case 'loading': return '<Spinner />'; case 'success': // TypeScript knows state.data exists here return `<UserCard name={state.data.name} />`; case 'error': // TypeScript knows state.message exists here return `<ErrorBanner message={state.message} />`; } }
Inside each case, TypeScript has narrowed the type fully. Accessing state.data outside of the 'success' case is a compile error. The impossible state is unrepresentable at the type level.
neverThe real power emerges when you add a new variant and want the compiler to force you to handle it everywhere. The never trick accomplishes this:
tsfunction assertNever(value: never): never { throw new Error(`Unhandled case: ${JSON.stringify(value)}`); } type Shape = | { kind: 'circle'; radius: number } | { kind: 'rectangle'; width: number; height: number } | { kind: 'triangle'; base: number; height: number }; function area(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'rectangle': return shape.width * shape.height; case 'triangle': return 0.5 * shape.base * shape.height; default: return assertNever(shape); // compile error if a case is missing } }
If you add | { kind: 'hexagon'; side: number } to Shape without adding a case 'hexagon' branch, TypeScript reports a type error at the assertNever(shape) line. The compiler becomes your checklist.
This pairs well with TypeScript generics — the AsyncState<T> example above shows how to combine both patterns.
Enums define a set of named constants, but they do not carry per-variant data. A discriminated union does both:
ts// Enum: can only represent the status, not associated data enum Status { Loading, Success, Error } // Discriminated union: each variant carries its own payload type RequestState<T> = | { tag: 'idle' } | { tag: 'loading'; startedAt: Date } | { tag: 'ok'; data: T; fetchedAt: Date } | { tag: 'err'; error: Error; retries: number };
The union version makes it impossible to access data when the request failed. With an enum you have separate fields that can get out of sync.
String literal discriminants (like tag: 'ok') are also serializable to JSON, which makes them straightforward to use with API responses. See typing API responses end to end for how this flows across the client/server boundary.
Forms are another classic use case. Instead of isSubmitting: boolean, isSuccess: boolean, error: string | null:
tstype FormState<TData> = | { phase: 'idle' } | { phase: 'submitting' } | { phase: 'success'; result: TData } | { phase: 'failed'; error: string }; function ContactForm() { const [state, setState] = React.useState<FormState<{ id: string }>>( { phase: 'idle' } ); // state.result only accessible in 'success' phase if (state.phase === 'success') { return <p>Submitted! ID: {state.result.id}</p>; } // ... }
This completely eliminates the class of bugs where you render state.result.id when state.error is set.
Narrowing works with if checks too, which is useful for inline JSX:
tstype Notification = | { type: 'info'; message: string } | { type: 'warning'; message: string; dismissable: boolean } | { type: 'error'; message: string; retryAction: () => void }; function Banner({ notif }: { notif: Notification }) { return ( <div> <p>{notif.message}</p> {notif.type === 'error' && ( <button onClick={notif.retryAction}>Retry</button> )} {notif.type === 'warning' && notif.dismissable && ( <button>Dismiss</button> )} </div> ); }
TypeScript narrows notif inside each conditional block without needing explicit casts.
Discriminated unions shine at compile time, but your type system cannot validate data that comes over the network. Pair them with runtime validation with Zod to get safety at both layers:
tsimport { z } from 'zod'; const ShapeSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('circle'), radius: z.number() }), z.object({ kind: z.literal('rectangle'), width: z.number(), height: z.number() }), ]); type Shape = z.infer<typeof ShapeSchema>; // At runtime, ShapeSchema.parse(body) throws if the shape is invalid // At compile time, Shape is a full discriminated union
Zod's z.discriminatedUnion even validates the discriminant first for better performance and clearer error messages than a plain z.union.
{ type: 'ADD_ITEM', payload: Item } | { type: 'REMOVE_ITEM', id: string })If you find yourself reaching for a boolean flag to signal a condition that already has a name, that is a signal a discriminated union variant is the right model. Explore more TypeScript patterns on the interview-prep page, or see the about page for more context on how these posts are written.
Discriminated unions make illegal states unrepresentable. The discriminant field gives TypeScript a single reliable anchor for narrowing, and assertNever turns the switch statement into an exhaustiveness check the compiler enforces automatically. Combined with generics and Zod validation, this pattern eliminates an entire category of runtime bugs before your code ships.