Skip to main content
Advertisement

6.1 Discriminated Unions

What Is a Discriminated Union?

When several types look similar, TypeScript needs a way to determine "what exact kind is this value?" A discriminated union places a shared literal field — the discriminant — in each type, and uses that field's value to distinguish between them.

The discriminant is typically a field named kind, type, tag, or status. Its value must be a unique string (or number) literal in each member type.

Discriminated union = shared literal field + union type

Benefits of discriminated unions:

  • TypeScript automatically narrows the type inside switch/if branches.
  • You get both runtime safety and compile-time safety at once.
  • When you add a new member type, the compiler warns you about any unhandled cases.

Core Concepts

1. Basic Discriminated Union

Group several interfaces that share a discriminant field into a union.

// Each shape is identified by its 'kind' field
interface Circle {
kind: 'circle'; // literal type
radius: number;
}

interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}

interface Triangle {
kind: 'triangle';
base: number;
height: number;
}

// Discriminated union
type Shape = Circle | Rectangle | Triangle;

2. switch + Type Narrowing

When you check the discriminant field in a switch statement, TypeScript narrows the type automatically inside each case block.

function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// shape is Circle inside this block
return Math.PI * shape.radius ** 2;
case 'rectangle':
// shape is Rectangle inside this block
return shape.width * shape.height;
case 'triangle':
// shape is Triangle inside this block
return (shape.base * shape.height) / 2;
}
}

3. Exhaustive Check with never

You can force a compile error if a newly added type is not handled anywhere.

// Exhaustive check helper using never
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
// Adding a new type to Shape causes a compile error here
return assertNever(shape);
}
}

Passing shape to assertNever works because, once all cases are handled, shape narrows to never. If any case is missing, shape still has a concrete type and the call fails to compile.


Code Examples

Example 1: kind-Based Basic Structure

interface LoadingState {
kind: 'loading';
}

interface SuccessState {
kind: 'success';
data: string[];
}

interface ErrorState {
kind: 'error';
message: string;
code: number;
}

type FetchState = LoadingState | SuccessState | ErrorState;

function renderState(state: FetchState): string {
switch (state.kind) {
case 'loading':
return 'Loading...';
case 'success':
return `Data: ${state.data.join(', ')}`;
case 'error':
return `Error [${state.code}]: ${state.message}`;
default:
return assertNever(state);
}
}

// Usage
const loading: FetchState = { kind: 'loading' };
const success: FetchState = { kind: 'success', data: ['apple', 'banana'] };
const error: FetchState = { kind: 'error', message: 'Network error', code: 500 };

console.log(renderState(loading)); // Loading...
console.log(renderState(success)); // Data: apple, banana
console.log(renderState(error)); // Error [500]: Network error

Example 2: Nested Discriminated Unions

Discriminated unions can be nested.

// Notification channels
interface EmailNotification {
channel: 'email';
to: string;
subject: string;
body: string;
}

interface SMSNotification {
channel: 'sms';
phoneNumber: string;
message: string;
}

interface PushNotification {
channel: 'push';
deviceToken: string;
title: string;
body: string;
badge?: number;
}

type Notification = EmailNotification | SMSNotification | PushNotification;

// Notification send result
interface NotificationResult {
notification: Notification;
status: 'sent' | 'failed';
timestamp: Date;
error?: string;
}

function formatNotification(notification: Notification): string {
switch (notification.channel) {
case 'email':
return `Email → ${notification.to}: ${notification.subject}`;
case 'sms':
return `SMS → ${notification.phoneNumber}: ${notification.message}`;
case 'push':
return `Push → ${notification.deviceToken}: ${notification.title}`;
default:
return assertNever(notification);
}
}

Example 3: Discriminated Union as a State Machine

Model an order processing flow as a discriminated union.

interface PendingOrder {
status: 'pending';
orderId: string;
items: string[];
createdAt: Date;
}

interface ConfirmedOrder {
status: 'confirmed';
orderId: string;
items: string[];
createdAt: Date;
confirmedAt: Date;
estimatedDelivery: Date;
}

interface ShippedOrder {
status: 'shipped';
orderId: string;
items: string[];
createdAt: Date;
confirmedAt: Date;
shippedAt: Date;
trackingNumber: string;
}

interface DeliveredOrder {
status: 'delivered';
orderId: string;
items: string[];
createdAt: Date;
deliveredAt: Date;
}

interface CancelledOrder {
status: 'cancelled';
orderId: string;
items: string[];
createdAt: Date;
cancelledAt: Date;
reason: string;
}

type Order =
| PendingOrder
| ConfirmedOrder
| ShippedOrder
| DeliveredOrder
| CancelledOrder;

// State transition functions — invalid transitions are type errors
function confirmOrder(order: PendingOrder): ConfirmedOrder {
return {
...order,
status: 'confirmed',
confirmedAt: new Date(),
estimatedDelivery: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
};
}

function shipOrder(order: ConfirmedOrder, trackingNumber: string): ShippedOrder {
return {
...order,
status: 'shipped',
shippedAt: new Date(),
trackingNumber,
};
}

function getOrderSummary(order: Order): string {
const base = `Order #${order.orderId}`;

switch (order.status) {
case 'pending':
return `${base} — Awaiting payment`;
case 'confirmed':
return `${base} — Preparing shipment (est. ${order.estimatedDelivery.toLocaleDateString()})`;
case 'shipped':
return `${base} — In transit (tracking: ${order.trackingNumber})`;
case 'delivered':
return `${base} — Delivered`;
case 'cancelled':
return `${base} — Cancelled (reason: ${order.reason})`;
default:
return assertNever(order);
}
}

Practical Examples

Example 1: HTTP Request State Management

// Generic discriminated union for reusable async state
type AsyncState<T, E = Error> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T; timestamp: Date }
| { kind: 'error'; error: E; retryCount: number };

// React-style hook simulation
interface User {
id: number;
name: string;
email: string;
}

type UserState = AsyncState<User, { message: string; statusCode: number }>;

function renderUserState(state: UserState): string {
switch (state.kind) {
case 'idle':
return 'Press the button to load data.';
case 'loading':
return 'Loading user information...';
case 'success':
return `
Name: ${state.data.name}
Email: ${state.data.email}
Fetched at: ${state.timestamp.toLocaleTimeString()}
`;
case 'error':
return `Error ${state.error.statusCode}: ${state.error.message} (retries: ${state.retryCount})`;
default:
return assertNever(state);
}
}

// State transition helpers
function toLoading<T, E>(state: AsyncState<T, E>): AsyncState<T, E> {
return { kind: 'loading' };
}

function toSuccess<T, E>(data: T): AsyncState<T, E> {
return { kind: 'success', data, timestamp: new Date() };
}

function toError<T, E>(error: E, retryCount = 0): AsyncState<T, E> {
return { kind: 'error', error, retryCount };
}

Example 2: Redux-Style Action Types

// Action type definitions
interface FetchUsersAction {
type: 'users/fetch';
}

interface FetchUsersSuccessAction {
type: 'users/fetchSuccess';
payload: User[];
}

interface FetchUsersFailureAction {
type: 'users/fetchFailure';
payload: string;
error: true;
}

interface AddUserAction {
type: 'users/add';
payload: Omit<User, 'id'>;
}

interface RemoveUserAction {
type: 'users/remove';
payload: number; // userId
}

interface UpdateUserAction {
type: 'users/update';
payload: Partial<User> & { id: number };
}

type UsersAction =
| FetchUsersAction
| FetchUsersSuccessAction
| FetchUsersFailureAction
| AddUserAction
| RemoveUserAction
| UpdateUserAction;

interface UsersState {
list: User[];
loading: boolean;
error: string | null;
}

// Reducer — handles all actions in a type-safe way
function usersReducer(state: UsersState, action: UsersAction): UsersState {
switch (action.type) {
case 'users/fetch':
return { ...state, loading: true, error: null };

case 'users/fetchSuccess':
return { ...state, loading: false, list: action.payload };

case 'users/fetchFailure':
return { ...state, loading: false, error: action.payload };

case 'users/add': {
const newUser: User = {
id: Math.max(0, ...state.list.map(u => u.id)) + 1,
...action.payload,
};
return { ...state, list: [...state.list, newUser] };
}

case 'users/remove':
return { ...state, list: state.list.filter(u => u.id !== action.payload) };

case 'users/update':
return {
...state,
list: state.list.map(u =>
u.id === action.payload.id ? { ...u, ...action.payload } : u
),
};

default:
return assertNever(action);
}
}

Example 3: Payment Method Processing

// Each payment method requires different data
interface CardPayment {
method: 'card';
cardNumber: string; // last 4 digits
expiryDate: string; // MM/YY
cardholderName: string;
cvv: string;
}

interface BankTransferPayment {
method: 'bankTransfer';
bankCode: string;
accountNumber: string;
accountHolder: string;
}

interface CouponPayment {
method: 'coupon';
couponCode: string;
discountType: 'percentage' | 'fixed';
discountValue: number;
}

interface PointPayment {
method: 'point';
pointsToUse: number;
availablePoints: number;
}

type PaymentMethod = CardPayment | BankTransferPayment | CouponPayment | PointPayment;

interface PaymentRequest {
orderId: string;
amount: number;
payment: PaymentMethod;
}

function validatePayment(payment: PaymentMethod, amount: number): string[] {
const errors: string[] = [];

switch (payment.method) {
case 'card':
if (!/^\d{4}$/.test(payment.cardNumber)) {
errors.push('Please enter the last 4 digits of your card number correctly.');
}
if (!/^\d{2}\/\d{2}$/.test(payment.expiryDate)) {
errors.push('Expiry date format is invalid (MM/YY).');
}
break;

case 'bankTransfer':
if (!payment.bankCode) {
errors.push('Please select a bank.');
}
if (!/^\d+$/.test(payment.accountNumber)) {
errors.push('Account number must contain only digits.');
}
break;

case 'coupon':
if (payment.discountType === 'percentage' && payment.discountValue > 100) {
errors.push('Discount rate cannot exceed 100%.');
}
if (payment.discountType === 'fixed' && payment.discountValue > amount) {
errors.push('Coupon discount amount exceeds the order total.');
}
break;

case 'point':
if (payment.pointsToUse > payment.availablePoints) {
errors.push(`Insufficient points (available: ${payment.availablePoints}).`);
}
if (payment.pointsToUse > amount) {
errors.push('Points used cannot exceed the payment amount.');
}
break;

default:
assertNever(payment);
}

return errors;
}

function getPaymentSummary(payment: PaymentMethod): string {
switch (payment.method) {
case 'card':
return `Credit/Debit Card (**** ${payment.cardNumber})`;
case 'bankTransfer':
return `Bank Transfer (${payment.accountHolder})`;
case 'coupon':
return `Coupon (${payment.couponCode}) — ${
payment.discountType === 'percentage'
? `${payment.discountValue}% off`
: `$${payment.discountValue} off`
}`;
case 'point':
return `Points (${payment.pointsToUse.toLocaleString()} pts used)`;
default:
return assertNever(payment);
}
}

Pro Tips

Tip 1: Exhaustive Check Helper Functions

// Option 1: runtime helper that throws
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled discriminated union case: ${JSON.stringify(value)}`);
}

// Option 2: compile-time only (zero runtime overhead)
function exhaustiveCheck(_: never): void {
// Used only at compile time
}

// Option 3: helper that returns a fallback value
function matchNever<T>(value: never, fallback: T): T {
return fallback;
}

// Usage
function processShape(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'rectangle': return shape.width * shape.height;
case 'triangle': return (shape.base * shape.height) / 2;
default:
exhaustiveCheck(shape); // triggers compile error only, unreachable at runtime
return 0; // TypeScript understands this is unreachable
}
}

Tip 2: Discriminated Unions vs Class Hierarchies

// Option A: Discriminated union (functional style)
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: 'Cannot divide by zero.' };
return { ok: true, value: a / b };
}

const result = divide(10, 2);
if (result.ok) {
console.log(result.value); // 5
} else {
console.error(result.error);
}

// Option B: Class hierarchy (OOP style)
abstract class ResultClass<T, E> {
abstract isOk(): this is OkResult<T, E>;
}

class OkResult<T, E> extends ResultClass<T, E> {
constructor(public readonly value: T) { super(); }
isOk(): this is OkResult<T, E> { return true; }
}

class ErrResult<T, E> extends ResultClass<T, E> {
constructor(public readonly error: E) { super(); }
isOk(): this is OkResult<T, E> { return false; }
}

// Prefer discriminated unions when:
// - The type is a simple data container
// - Serialization/deserialization is needed (JSON)
// - Functional patterns are preferred
// - Types are frequently composed or split

// Prefer class hierarchies when:
// - Methods and state are tightly coupled
// - An inheritance hierarchy is meaningful
// - Dependency injection or mocking is required

Tip 3: Utility Types for Discriminated Unions

// Extract discriminant values from a discriminated union
type DiscriminatorValues<
T,
K extends keyof T
> = T extends Record<K, infer V> ? V : never;

type ShapeKind = DiscriminatorValues<Shape, 'kind'>;
// 'circle' | 'rectangle' | 'triangle'

// Extract the specific member type by its discriminant value
type ExtractByDiscriminator<
T,
K extends keyof T,
V extends T[K]
> = T extends Record<K, V> ? T : never;

type CircleType = ExtractByDiscriminator<Shape, 'kind', 'circle'>;
// Circle

Tip 4: Implementing a match Helper

// Pattern-matching helper for discriminated unions
type MatchHandlers<T extends { kind: string }, R> = {
[K in T['kind']]: (value: Extract<T, { kind: K }>) => R;
};

function match<T extends { kind: string }, R>(
value: T,
handlers: MatchHandlers<T, R>
): R {
const handler = handlers[value.kind as T['kind']];
return (handler as (v: T) => R)(value);
}

// Usage
const area = match(shape, {
circle: s => Math.PI * s.radius ** 2,
rectangle: s => s.width * s.height,
triangle: s => (s.base * s.height) / 2,
});

Summary Table

ConceptDescriptionWhen to Use
Discriminated unionUnion type distinguished by a shared literal fieldWhen multiple data shapes need a single type
DiscriminantA literal-type field such as kind, type, or tagWhen each case must be uniquely identified
switch narrowingType is automatically narrowed inside each case blockWhen branching on the discriminant
Exhaustive checkCompile-time verification that all cases are handled using neverWhen union members may be added later
assertNeverHelper that throws a runtime error for unhandled casesDefensive programming
State machineEncoding state transitions as type constraintsOrders, payments, form flows, etc.
Unions vs classesData-centric → unions; method-centric → classesDepends on design direction

Next we will look at Type Guards (6.2), which are closely related to discriminated unions. You will learn type-narrowing techniques from typeof, instanceof, and in operators through user-defined type guards (value is Type) and assertion functions.

Advertisement