Skip to main content

TypeScript Discriminated Unions Explained

You're fetching user data from an API. Your state needs to track whether you're loading, have data, or hit an error. So you create an object with optional properties: { loading?: boolean, data?: User, error?: string }. But now you can end up with impossible states: both loading and data set, or error and data at the same time. TypeScript won't stop you from accessing data when it's actually undefined.

Discriminated unions solve this by letting you define a type that can represent several distinct states, each identified by a shared property called a "discriminant." Once you check that property, TypeScript knows exactly which state you're in and what properties are available. This pattern helps you model complex data flows while catching bugs at compile time instead of runtime.

Building Your First TypeScript Discriminated Union

Here's how discriminated unions handle the API response problem. Instead of optional properties that allow invalid states, you define each state as its own type with a shared discriminant property:

interface LoadingState {
status: 'loading';
}

interface SuccessState {
status: 'success';
data: User[];
}

interface ErrorState {
status: 'error';
message: string;
}

type ApiResponse = LoadingState | SuccessState | ErrorState;

The status property is your discriminant. It's a literal type ('loading', 'success', or 'error'), not a generic string. This lets TypeScript narrow down which exact state you're dealing with when you check the status value.

Now you can't accidentally create { status: 'loading', data: [] } because LoadingState doesn't have a data property. Each state is well-defined and mutually exclusive.

This pattern works well with Convex's schema validation system, where you can define validators for different object shapes in your database.

TypeScript Type Narrowing with Discriminated Unions

Once you've defined your discriminated union, TypeScript automatically narrows the type based on your discriminant checks. Here's how you'd handle that API response:

function handleApiResponse(response: ApiResponse) {
if (response.status === 'loading') {
// TypeScript knows: response is LoadingState
return <Spinner />;
}

if (response.status === 'error') {
// TypeScript knows: response is ErrorState
// So response.message is available and type-safe
return <ErrorDisplay message={response.message} />;
}

// TypeScript knows: response must be SuccessState
// So response.data is available
return <UserList users={response.data} />;
}

When you check response.status === 'loading', TypeScript narrows response to LoadingState. It knows that within that branch, you can't access response.message or response.data because they don't exist on LoadingState. By checking the discriminant value, TypeScript knows exactly which interface the object conforms to.

This pattern is similar to how Convex handles different document types in its database, where you can validate different shapes based on a type field.

Exhaustive Checking with TypeScript's Never Type

What happens when you add a new state to your union but forget to handle it in your code? TypeScript can catch this at compile time using exhaustive checking with the never type.

Here's a helper function that ensures you've handled all cases:

function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}

function handleApiResponse(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorDisplay message={response.message} />;
default:
// If all cases are handled, response has type 'never' here
return assertNever(response);
}
}

If you later add a RefreshingState to your ApiResponse union but don't add a case for it, TypeScript will error on the assertNever(response) line because response won't be never anymore—it'll be RefreshingState. This forces you to handle the new case.

Without this pattern, you might deploy code that silently fails when it encounters the new state. The compiler becomes your safety net.

This approach works well when building end-to-end type-safe applications where you need to ensure all cases are properly handled across your codebase.

Discriminated Unions vs Optional Properties

Why use discriminated unions instead of a single interface with optional properties? Let's compare the two approaches for handling payment methods:

Optional Properties Approach (Problematic):

interface Payment {
method: 'card' | 'paypal' | 'crypto';
cardNumber?: string;
paypalEmail?: string;
walletAddress?: string;
}

// This compiles but is invalid
const payment: Payment = {
method: 'card',
paypalEmail: 'user@example.com', // Wrong! Card payments don't use PayPal
};

Discriminated Union Approach (Type-Safe):

interface CardPayment {
method: 'card';
cardNumber: string;
cvv: string;
}

interface PayPalPayment {
method: 'paypal';
paypalEmail: string;
}

interface CryptoPayment {
method: 'crypto';
walletAddress: string;
}

type Payment = CardPayment | PayPalPayment | CryptoPayment;

// TypeScript won't let you mix properties from different payment types
function processPayment(payment: Payment) {
switch (payment.method) {
case 'card':
return validateCard(payment.cardNumber, payment.cvv);
case 'paypal':
return sendToPayPal(payment.paypalEmail);
case 'crypto':
return sendToCryptoWallet(payment.walletAddress);
}
}

With discriminated unions, impossible states become unrepresentable. You can't create a card payment with a PayPal email. The typeof operator can also be useful when working with discriminated unions to check types at runtime.

This pattern is similar to Convex's approach to filtering complex data, where you can handle different data cases efficiently.

Where Developers Get Stuck

Destructuring Breaks Type Narrowing

A common pitfall: if you destructure a discriminated union before checking the discriminant, TypeScript loses track of the type refinement.

// This doesn't work as expected
function handleResponse(response: ApiResponse) {
const { status } = response; // Destructured too early

if (status === 'success') {
// ERROR: TypeScript doesn't know response.data exists
return response.data;
}
}

// Do this instead
function handleResponse(response: ApiResponse) {
if (response.status === 'success') {
// Now you can safely destructure
const { data } = response;
return data;
}
}

TypeScript can't track that response is a SuccessState just because you checked a destructured status variable. Check the discriminant property directly on the object, then destructure inside the narrowed branch.

Using Non-Literal Discriminant Types

Your discriminant must be a literal type, not a general string or number:

// Wrong - discriminant is too broad
interface BadExample {
type: string; // Any string, not specific literals
data: unknown;
}

// Right - discriminant is a specific literal
interface GoodExample {
type: 'user' | 'admin'; // Only these exact strings
data: unknown;
}

Without literal types, TypeScript can't narrow the union because it doesn't know which specific values map to which union members. The keyof operator can be useful when creating flexible type guards for discriminated unions.

When debugging TypeScript code, understanding these limitations helps you write more reliable type-safe code.

Modeling Complex State Machines

Discriminated unions excel at modeling state machines where each state has its own data and valid transitions. Here's a file upload flow:

interface IdleState {
status: 'idle';
}

interface UploadingState {
status: 'uploading';
progress: number;
fileName: string;
cancelToken: AbortController;
}

interface UploadedState {
status: 'uploaded';
fileName: string;
fileUrl: string;
uploadedAt: Date;
}

interface FailedState {
status: 'failed';
fileName: string;
error: Error;
retryCount: number;
}

type UploadState = IdleState | UploadingState | UploadedState | FailedState;

function renderUploadUI(state: UploadState) {
switch (state.status) {
case 'idle':
return <UploadButton />;
case 'uploading':
return <ProgressBar progress={state.progress} onCancel={() => state.cancelToken.abort()} />;
case 'uploaded':
return <SuccessMessage url={state.fileUrl} uploadedAt={state.uploadedAt} />;
case 'failed':
return <ErrorMessage error={state.error} retryCount={state.retryCount} />;
}
}

Each state carries exactly the data it needs. You can't access progress when you're in the idle state because that property doesn't exist. The extends keyword can also be useful for creating base types with discriminant properties if you need shared fields across states.

This approach aligns with Convex's custom functions pattern, where different function types can share base functionality while maintaining their unique behaviors.

Writing Clean Discriminated Unions

Here are patterns that'll make your discriminated unions easier to work with:

Choose Consistent Discriminant Names

Pick a discriminant property name and stick with it across your codebase. Common choices are type, kind, status, or variant. Consistency helps other developers (and future you) recognize the pattern immediately.

Prefer String Literals Over Numbers

// Harder to debug
type EventType = { kind: 0; /* ... */ } | { kind: 1; /* ... */ };

// Self-documenting
type EventType = { kind: 'click'; /* ... */ } | { kind: 'scroll'; /* ... */ };

String literals show up in debugging tools and error messages, making issues easier to track down.

Keep all the types in a discriminated union in the same file or module. When you need to add a new variant, you'll know exactly where to look.

// user-events.ts
interface UserLogin { type: 'login'; userId: string; timestamp: Date; }
interface UserLogout { type: 'logout'; userId: string; sessionDuration: number; }
interface UserUpdate { type: 'update'; userId: string; changes: Partial<User>; }

export type UserEvent = UserLogin | UserLogout | UserUpdate;

The union types feature is central to discriminated unions, and understanding how to properly structure them improves code organization.

For complex applications, consider combining this pattern with Convex's helpers for traversing relationships between different data types.

Final Thoughts on TypeScript Discriminated Unions

The next time you reach for optional properties or a bunch of boolean flags to track state, consider whether a discriminated union would make your types more honest. They force you to model your domain accurately: if two properties should never exist together, don't put them in the same interface.

Start simple. Model your API responses with loading | success | error states. Once you see how the compiler catches bugs before they reach production, you'll find more places where discriminated unions clarify your code. State machines, event handlers, form validation, polymorphic components—they all benefit from this pattern.

The upfront effort of defining separate types pays off when you refactor. Add a new state to your union, and TypeScript will show you every place you need to handle it. That's the kind of confidence that lets you move fast without breaking things.