SyntaxStudy
Sign Up
TypeScript Discriminated Unions and Exhaustive Narrowing
TypeScript Beginner 1 min read

Discriminated Unions and Exhaustive Narrowing

Discriminated unions are a powerful pattern for modelling variants of a type. Each variant has a common discriminant property — usually a string literal — that uniquely identifies it. TypeScript uses this discriminant to narrow the union in switch statements and if chains. This pattern makes impossible states unrepresentable and eliminates the need for optional properties. When used in a switch on the discriminant, TypeScript tracks which variants have been handled and narrows the remaining type in the default case. After all variants are handled, the remaining type is never. Pairing this with an assertNever call in the default case turns missing cases into compile-time errors rather than silent runtime bugs. Discriminated unions model state machines naturally. Each state is a union variant with the data relevant only to that state. State transitions are functions that take one variant and return another. This forces the data and the state to be consistent — you cannot accidentally access properties that are only valid in another state. The result is code that is both safer and more self-documenting.
Example
// Remote data — discriminated union for async state
type RemoteData<T> =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: T }
    | { status: "error";   error: string };

interface Post { id: number; title: string }

function renderPost(state: RemoteData<Post>): string {
    switch (state.status) {
        case "idle":    return "Not started";
        case "loading": return "Loading...";
        case "success": return `Post: ${state.data.title}`;
        case "error":   return `Error: ${state.error}`;
        default:
            const _exhaustive: never = state;
            throw new Error(`Unhandled state: ${JSON.stringify(_exhaustive)}`);
    }
}

// Narrowing with multiple discriminants
type UserAction =
    | { type: "LOGIN";  payload: { userId: number; token: string } }
    | { type: "LOGOUT" }
    | { type: "UPDATE"; payload: { field: string; value: unknown } };

function reducer(action: UserAction): string {
    switch (action.type) {
        case "LOGIN":
            return `User ${action.payload.userId} logged in`;
        case "LOGOUT":
            return "User logged out";
        case "UPDATE":
            return `Updated ${action.payload.field}`;
    }
    // TypeScript knows we've covered all cases — no default needed
}

// Type narrowing with control flow
function processValue(x: string | number | string[]) {
    if (Array.isArray(x)) {
        return x.join(", "); // string[]
    } else if (typeof x === "string") {
        return x.trim();     // string
    } else {
        return x.toFixed(2); // number
    }
}