Polyglot
Languages TypeScript pattern matching
TypeScript § pattern-matching

Pattern dispatch

TypeScript does not have pattern matching in the sense of Rust, Haskell, or Scala — there is no destructuring with bindings, no algebraic-data-type analysis, no exhaustive checking baked into a match construct. The conventional dispatch is switch (inherited from JavaScript), which has the C-family fallthrough semantics. The TypeScript additions are at the type level: discriminated unions admit substantial type narrowing within switch and if/else if chains; assigning the discriminant to never in the default branch admits compile-time exhaustiveness checking. The combination — JavaScript’s switch plus discriminated unions plus the never exhaustiveness check — is the substance of TypeScript’s pattern-dispatch surface. Object destructuring and array destructuring admit shape-based extraction at binding sites.

switch

The form:

switch (n) {
    case 0:
        console.log("zero");
        break;
    case 1:
        console.log("one");
        break;
    case 2:
    case 3:
        console.log("two or three");             // multiple labels
        break;
    default:
        console.log("other");
}

Each case must end with break, return, throw, or continue (in a loop) — or fall through to the next case. The fallthrough is the C-family default; conventional discipline always uses break:

switch (n) {
    case 1:
        console.log("one");
        // fallthrough; "two" prints for n=1 too
    case 2:
        console.log("two");
        break;
}

The conventional ESLint rule no-fallthrough flags missing breaks.

switch with discriminated unions

The conventional contemporary use of switch in TypeScript is dispatching on the discriminant of a discriminated union:

type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; base: number; height: number };

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":
            return Math.PI * s.radius ** 2;       // narrowed to circle
        case "square":
            return s.side ** 2;                  // narrowed to square
        case "triangle":
            return (s.base * s.height) / 2;      // narrowed to triangle
    }
}

The compiler narrows s within each branch based on the discriminant; the type-specific properties are accessible without further checks. The pattern is one of TypeScript’s most distinctive idioms.

Exhaustiveness checking

To verify that all variants are handled, assign the discriminant to a never-typed variable in the default branch:

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":   return Math.PI * s.radius ** 2;
        case "square":   return s.side ** 2;
        case "triangle": return (s.base * s.height) / 2;
        default:
            const _exhaustive: never = s;        // ERROR if s is not narrowed to never
            return _exhaustive;
    }
}

If a new variant is added to Shape, the compiler reports an error in area: s cannot be narrowed to never because there is an uncovered case. The mechanism admits compile-time exhaustiveness checking.

A reusable helper:

function assertNever(x: never): never {
    throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":   return Math.PI * s.radius ** 2;
        case "square":   return s.side ** 2;
        case "triangle": return (s.base * s.height) / 2;
        default:         return assertNever(s);
    }
}

The helper is conventional in TypeScript codebases; treated in Narrowing.

Multiple labels

Several values may share a case body via stacked labels:

switch (day) {
    case "Saturday":
    case "Sunday":
        console.log("weekend");
        break;
    case "Monday":
    case "Tuesday":
    case "Wednesday":
    case "Thursday":
    case "Friday":
        console.log("weekday");
        break;
}

switch with no discriminant

A conditional switch with true admits expressing a chain of conditions:

switch (true) {
    case n < 0:
        return "negative";
    case n === 0:
        return "zero";
    case n < 10:
        return "small";
    default:
        return "large";
}

The form is admitted but rare in idiomatic TypeScript; if/else if/else is conventionally clearer.

Object destructuring

Object destructuring admits “extracting fields by name” at the binding site:

const person = { name: "Alice", age: 30, email: "alice@example.com" };

const { name, age } = person;
console.log(name, age);                          // "Alice" 30

// Rename:
const { name: n, age: a } = person;

// Default value:
const { nickname = "anon" } = person;

// Rest:
const { name: nm, ...rest } = person;
console.log(rest);                                // { age: 30, email: "..." }

// Nested:
const { profile: { displayName } } = user;

// In function parameters:
function greet({ name, age }: { name: string; age: number }) {
    console.log(`${name} is ${age}`);
}

The form admits substantial conciseness for parameter handling and value extraction.

Array destructuring

Array destructuring admits “extracting elements by position”:

const arr = [1, 2, 3, 4, 5];

const [a, b, c] = arr;                           // 1, 2, 3

// Skip:
const [, , third] = arr;                          // 3

// Rest:
const [first, ...rest] = arr;
console.log(rest);                                // [2, 3, 4, 5]

// Default:
const [a = 0, b = 0] = [];

// Nested:
const [[x, y], [z]] = [[1, 2], [3]];

// Swap:
let a = 1, b = 2;
[a, b] = [b, a];

The form admits substantial conciseness for tuple-like return values.

Combined destructuring

Object and array destructuring combine:

const data = {
    name: "Alice",
    scores: [95, 87, 76],
};

const { name, scores: [first, second] } = data;

// Function returning a tuple:
function divmod(a: number, b: number): [number, number] {
    return [Math.floor(a / b), a % b];
}

const [q, r] = divmod(17, 5);

if/else if chains for narrowing

For dispatch on type rather than value, if/else if chains with type guards admit substantial discrimination:

function process(x: string | number | Date | null) {
    if (x === null) {
        return "null";
    } else if (typeof x === "string") {
        return x.toUpperCase();
    } else if (typeof x === "number") {
        return x.toFixed(2);
    } else {                                      // x is Date here
        return x.toISOString();
    }
}

For exhaustiveness, the trailing else admits the same never-assignment pattern:

function process(x: A | B | C): string {
    if (isA(x)) return handleA(x);
    if (isB(x)) return handleB(x);
    if (isC(x)) return handleC(x);
    const _exhaustive: never = x;
    return _exhaustive;
}

Common patterns

Discriminated union dispatch

type Action =
    | { type: "INCREMENT"; by: number }
    | { type: "DECREMENT"; by: number }
    | { type: "RESET" }
    | { type: "SET"; value: number };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case "INCREMENT": return state + action.by;
        case "DECREMENT": return state - action.by;
        case "RESET":     return 0;
        case "SET":       return action.value;
        default:          return assertNever(action);
    }
}

State machine

type State =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: string }
    | { status: "error"; message: string };

function describe(state: State): string {
    switch (state.status) {
        case "idle":    return "Ready";
        case "loading": return "Loading...";
        case "success": return state.data;
        case "error":   return `Error: ${state.message}`;
        default:        return assertNever(state);
    }
}

Token dispatch (parser)

type Token =
    | { kind: "number"; value: number }
    | { kind: "string"; value: string }
    | { kind: "ident"; name: string }
    | { kind: "lparen" }
    | { kind: "rparen" };

function describe(t: Token): string {
    switch (t.kind) {
        case "number": return `number(${t.value})`;
        case "string": return `string(${JSON.stringify(t.value)})`;
        case "ident":  return `ident(${t.name})`;
        case "lparen": return "(";
        case "rparen": return ")";
        default:       return assertNever(t);
    }
}

HTTP method dispatch

function handle(request: { method: string; body: unknown }): Response {
    switch (request.method) {
        case "GET":    return handleGet();
        case "POST":   return handlePost(request.body);
        case "PUT":    return handlePut(request.body);
        case "DELETE": return handleDelete();
        default:       return new Response("method not allowed", { status: 405 });
    }
}

Lookup table

For substantial dispatch, a lookup table is conventionally cleaner than switch:

type Action =
    | { type: "INCREMENT"; by: number }
    | { type: "DECREMENT"; by: number };

const handlers: { [K in Action["type"]]: (state: number, action: Extract<Action, { type: K }>) => number } = {
    INCREMENT: (s, a) => s + a.by,
    DECREMENT: (s, a) => s - a.by,
};

function reducer(state: number, action: Action): number {
    return handlers[action.type](state, action as any);
}

The form admits adding new action types by adding a handler entry; type checking ensures coverage.

Object destructuring with rename

const { displayName: name = "anon", level: lvl = 1 } = user;

Array head/tail

function process<T>(items: T[]): void {
    if (items.length === 0) return;

    const [head, ...tail] = items;
    handle(head);
    process(tail);                                // recursive
}

Tuple return values

function useState<T>(initial: T): [T, (next: T) => void] {
    let value = initial;
    const setter = (next: T) => { value = next; };
    return [value, setter];
}

const [count, setCount] = useState(0);

The pattern is the conventional React pattern.

Type-narrowing chains

function describe(x: unknown): string {
    if (x === null) return "null";
    if (x === undefined) return "undefined";
    if (typeof x === "string") return `"${x}"`;
    if (typeof x === "number") return String(x);
    if (typeof x === "boolean") return x ? "true" : "false";
    if (Array.isArray(x)) return `[${x.length}]`;
    if (x instanceof Date) return x.toISOString();
    if (typeof x === "object") return JSON.stringify(x);
    return "<unknown>";
}

Combined dispatch (switch + if)

function processCommand(cmd: Command): void {
    switch (cmd.kind) {
        case "save":
            if (cmd.target === "all") saveAll();
            else saveSelected(cmd.target);
            break;
        case "load":
            if (cmd.path) loadFromPath(cmd.path);
            else loadDefault();
            break;
    }
}

Comparison with match

Languages with substantial pattern matching admit:

  • Destructuring with bindingsmatch shape with | Circle r => ....
  • Nested patternsmatch (a, b) with | (Some x, Some y) => ....
  • Guards on patternsmatch n with | x when x > 0 => ....
  • Exhaustiveness as part of the construct.

TypeScript’s switch does not admit any of these directly. The conventional substitutes:

  • Object destructuring in case bodies — case "circle": const { radius } = s;.
  • Type narrowing via the discriminant.
  • Nested switch or if for sub-discrimination.
  • The never trick for exhaustiveness.

The TC39 Pattern Matching proposal admits match syntax in JavaScript; as of 2026, the proposal remains at Stage 1 / 2.

A note on the conventional discipline

The contemporary TypeScript pattern-dispatch advice:

  • Use switch for value or discriminant dispatch.
  • Use discriminated unions for closed sets of variants.
  • Use the never exhaustiveness check — assign the discriminant to never in default.
  • Use assertNever as a reusable helper.
  • Use object destructuring for shape extraction.
  • Use array destructuring for tuple-like values.
  • Use if/else if chains with type guards for type-based dispatch.
  • Always break unless fallthrough is intended.
  • Avoid switch (true)if/else if is conventionally clearer.

The combination — switch with multi-label cases, discriminated unions with narrowing, exhaustiveness checking via never, destructuring at binding sites — is the substance of TypeScript’s pattern-dispatch surface. The discipline produces clear, type-safe dispatch code; the trade-off compared with substantive pattern matching is some additional verbosity, recovered through the type system’s narrowing.