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 bindings —
match shape with | Circle r => .... - Nested patterns —
match (a, b) with | (Some x, Some y) => .... - Guards on patterns —
match 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
switchoriffor sub-discrimination. - The
nevertrick 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
switchfor value or discriminant dispatch. - Use discriminated unions for closed sets of variants.
- Use the
neverexhaustiveness check — assign the discriminant toneverin default. - Use
assertNeveras a reusable helper. - Use object destructuring for shape extraction.
- Use array destructuring for tuple-like values.
- Use
if/else ifchains with type guards for type-based dispatch. - Always
breakunless fallthrough is intended. - Avoid
switch (true)—if/else ifis 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.