TypeScript Discriminated Unions: Tagged Union Types
Discriminated unions, also called tagged unions or disjoint unions, are a TypeScript pattern that combines union types with a common literal property to enable type-safe branching logic. They solve a...
Key Insights
- Discriminated unions use a shared literal property to enable type-safe pattern matching, eliminating entire classes of runtime errors through compile-time checking
- TypeScript automatically narrows union types when you check the discriminant property, giving you full IntelliSense and type safety in each branch
- The
nevertype provides exhaustiveness checking that catches unhandled cases at compile time, protecting your code when new union members are added
What Are Discriminated Unions?
Discriminated unions, also called tagged unions or disjoint unions, are a TypeScript pattern that combines union types with a common literal property to enable type-safe branching logic. They solve a fundamental problem: how do you model data that can be one of several mutually exclusive shapes while maintaining complete type safety?
A discriminated union requires three ingredients:
- A common literal type property (the discriminant or tag) shared across all types
- A union type combining the individual types
- Type guards that check the discriminant property
This pattern is invaluable when modeling states that can’t coexist. A network request can’t be both loading and successful. A shape can’t be both a circle and a rectangle. Discriminated unions make these constraints explicit in your type system.
type Circle = {
kind: 'circle';
radius: number;
};
type Square = {
kind: 'square';
sideLength: number;
};
type Rectangle = {
kind: 'rectangle';
width: number;
height: number;
};
type Shape = Circle | Square | Rectangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows shape is Square here
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows shape is Rectangle here
return shape.width * shape.height;
}
}
The kind property is our discriminant. TypeScript uses it to narrow the type within each branch, giving you autocomplete and type checking for the specific shape’s properties.
Building a Discriminated Union
Creating a discriminated union starts with defining individual types that share a common property with distinct literal values. This discriminant property must use literal types—specific strings, numbers, or booleans—not general types like string or number.
type SuccessResponse<T> = {
status: 'success';
data: T;
timestamp: Date;
};
type ErrorResponse = {
status: 'error';
error: {
code: string;
message: string;
};
timestamp: Date;
};
type LoadingResponse = {
status: 'loading';
progress?: number;
};
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse | LoadingResponse;
Choose discriminant property names that make semantic sense. Common choices include type, kind, status, tag, and variant. The name should clearly indicate its role as a type discriminator. Consistency across your codebase matters—pick a convention and stick with it.
Each type in the union should have a unique value for the discriminant property. This uniqueness is what enables TypeScript to narrow types effectively.
Type Narrowing with Discriminants
TypeScript’s control flow analysis automatically narrows union types when you check the discriminant property. This works with switch statements, if/else chains, and even early returns.
type CardPayment = {
method: 'card';
cardNumber: string;
cvv: string;
expiryDate: string;
};
type PayPalPayment = {
method: 'paypal';
email: string;
};
type BankTransferPayment = {
method: 'bank_transfer';
accountNumber: string;
routingNumber: string;
};
type Payment = CardPayment | PayPalPayment | BankTransferPayment;
function processPayment(payment: Payment): void {
switch (payment.method) {
case 'card':
// payment is CardPayment
console.log(`Processing card ending in ${payment.cardNumber.slice(-4)}`);
// payment.email would be a type error here
break;
case 'paypal':
// payment is PayPalPayment
console.log(`Processing PayPal payment for ${payment.email}`);
break;
case 'bank_transfer':
// payment is BankTransferPayment
console.log(`Processing bank transfer from ${payment.accountNumber}`);
break;
}
}
The type narrowing is automatic and precise. Within each case, you have access to exactly the properties defined for that variant, and TypeScript will error if you try to access properties from other variants.
Exhaustiveness Checking
One of the most powerful features of discriminated unions is exhaustiveness checking—ensuring you’ve handled every possible case. This protects you when someone adds a new variant to the union later.
The pattern uses TypeScript’s never type, which represents values that should never occur:
type EmailNotification = {
type: 'email';
recipient: string;
subject: string;
body: string;
};
type SmsNotification = {
type: 'sms';
phoneNumber: string;
message: string;
};
type PushNotification = {
type: 'push';
deviceId: string;
title: string;
body: string;
};
type Notification = EmailNotification | SmsNotification | PushNotification;
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
function sendNotification(notification: Notification): void {
switch (notification.type) {
case 'email':
console.log(`Sending email to ${notification.recipient}`);
break;
case 'sms':
console.log(`Sending SMS to ${notification.phoneNumber}`);
break;
case 'push':
console.log(`Sending push to device ${notification.deviceId}`);
break;
default:
// If all cases are handled, notification is never here
assertNever(notification);
}
}
If you later add a WebhookNotification to the Notification union without updating sendNotification, TypeScript will error at the assertNever call because notification would be WebhookNotification, not never. This compile-time check catches bugs before they reach production.
Real-World Use Cases
Discriminated unions excel at modeling state machines and complex application states. Consider form field validation:
type IdleField = {
state: 'idle';
value: string;
};
type ValidatingField = {
state: 'validating';
value: string;
};
type ValidField = {
state: 'valid';
value: string;
validatedAt: Date;
};
type InvalidField = {
state: 'invalid';
value: string;
errors: string[];
};
type FieldState = IdleField | ValidatingField | ValidField | InvalidField;
function renderField(field: FieldState): string {
switch (field.state) {
case 'idle':
return `<input value="${field.value}" />`;
case 'validating':
return `<input value="${field.value}" /> <spinner />`;
case 'valid':
return `<input value="${field.value}" class="valid" /> ✓`;
case 'invalid':
return `<input value="${field.value}" class="invalid" /> ${field.errors.join(', ')}`;
}
}
This pattern makes impossible states impossible. A field can’t be both validating and invalid. The errors array only exists when the state is invalid, preventing the common bug of checking stale error messages.
Advanced Patterns
Discriminated unions compose well with other TypeScript features. You can nest them, combine them with generics, and use them recursively:
type File = {
type: 'file';
name: string;
size: number;
content: string;
};
type Directory = {
type: 'directory';
name: string;
children: FileSystemNode[];
};
type Symlink = {
type: 'symlink';
name: string;
target: string;
};
type FileSystemNode = File | Directory | Symlink;
function getSize(node: FileSystemNode): number {
switch (node.type) {
case 'file':
return node.size;
case 'directory':
return node.children.reduce((total, child) => total + getSize(child), 0);
case 'symlink':
return 0; // Symlinks themselves have no size
}
}
This recursive structure is type-safe at every level. TypeScript tracks the discriminant through the recursion, ensuring you can’t accidentally treat a file as a directory.
Compared to enums, discriminated unions offer more flexibility. Each variant can carry different associated data, while enum members are just identifiers. Use enums for simple sets of constants; use discriminated unions when each case needs its own data structure.
Common Pitfalls and Solutions
The most common mistake is forgetting to make the discriminant property a literal type:
// ❌ Wrong: status is typed as string, not a literal
type BadResponse = {
status: string;
data: unknown;
};
// ✓ Correct: status is a literal type
type GoodResponse = {
status: 'success';
data: unknown;
};
Another pitfall is type widening when creating instances:
// ❌ Wrong: TypeScript infers { status: string }
const response = {
status: 'success',
data: { id: 1 }
};
// ✓ Correct: Use const assertion or explicit type
const response = {
status: 'success' as const,
data: { id: 1 }
};
// Or
const response: SuccessResponse<{ id: number }> = {
status: 'success',
data: { id: 1 }
};
Always ensure your discriminant properties use literal types and that instances are properly typed. When in doubt, add explicit type annotations.
Discriminated unions are one of TypeScript’s most powerful features for modeling domain logic. They bring the benefits of algebraic data types from functional languages to TypeScript, enabling you to write code that’s both type-safe and self-documenting. Master this pattern, and you’ll write fewer runtime checks, catch more bugs at compile time, and build more maintainable applications.