Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Pattern Matching with Switch Statements in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Discriminated Unions and Tagged Types in TypeScript
TypeScript
10m
May 3, 2026

Discriminated Unions and Tagged Types in TypeScript

Discriminated unions are one of TypeScript's most powerful patterns for modelling state. A shared literal property acts as a tag, letting TypeScript narrow each union member precisely — and flag missing cases before they reach production.

#Discriminated Unions#Tagged Types+5
Type Guards: Runtime Type Checking in TypeScript
TypeScript
9m
Apr 26, 2026

Type Guards: Runtime Type Checking in TypeScript

TypeScript knows about types at compile time, but values arrive at runtime. Type guards bridge that gap — narrowing a broad type to a specific one so TypeScript can help you handle each case correctly.

#Type Guards#Type Narrowing+5
Literal Types and Const Assertions in TypeScript
TypeScript
7m
Apr 19, 2026

Literal Types and Const Assertions in TypeScript

A practical guide to TypeScript literal types and as const — narrowing values to exact possibilities, building type-safe enumerations, and preserving tuple and object shapes.

#Literal Types#Const Assertions+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Switch Statements and Type Narrowing
  • Exhaustiveness Revisited
  • The assertNever Utility
  • Pattern Matching Helpers
  • Handling Nested Variants
  • Real-World Examples
  • Best Practices
  • Key Takeaways
TypeScript

Pattern Matching with Switch Statements in TypeScript

May 10, 2026•8 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript pattern matching with switch statements
Pattern Matching with Switch Statements in TypeScript

Switch Statements and Type Narrowing

A switch statement in plain JavaScript is a convenience — a cleaner way to write a chain of if/else if comparisons. In TypeScript, it's a type narrowing tool. When you switch on the discriminant of a discriminated union, TypeScript narrows the full type inside each case block automatically.

In the last post, you built discriminated unions and used equality checks to narrow them. Switch statements formalise that pattern and add something valuable: a default branch that TypeScript can use to verify you've handled every variant.

Let's start with the shapes example from the previous post and work through increasingly powerful patterns:

type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Rectangle | Triangle;
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // TypeScript knows: shape is Circle
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      // TypeScript knows: shape is Rectangle
      return shape.width * shape.height;
    case "triangle":
      // TypeScript knows: shape is Triangle
      return 0.5 * shape.base * shape.height;
  }
}

TypeScript narrows shape to the correct variant inside each case. Access shape.radius in the "rectangle" case and you get a compile error. Every property access is checked against the actual type in that branch.

Switch Expressions Are Coming

Many languages have a switch expression — a version that returns a value directly, like a ternary. TypeScript doesn't have one yet (it's a JavaScript proposal in early stages), but you can simulate it with an immediately invoked function or a helper. The pattern is shown in the Pattern Matching Helpers section below.

Exhaustiveness Revisited

Notice that the area function above has no default case. TypeScript is smart enough to know that Circle | Rectangle | Triangle is fully covered by those three cases, so it doesn't complain about a missing return. But there's a fragility: add a fourth shape and the function silently falls through to undefined, which TypeScript might or might not catch depending on your strictNullChecks settings.

The safer approach is to always include a default case that makes the missing-variant problem explicit:

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default: {
      // If TypeScript reaches here, shape has type never
      // If it doesn't, you have an unhandled variant
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
    }
  }
}

Now add type Ellipse = { kind: "ellipse"; semiMajor: number; semiMinor: number } to Shape and TypeScript immediately reports: Type 'Ellipse' is not assignable to type 'never'. The default branch has become a tripwire — the moment the union grows, the compiler tells you exactly where the gap is.

The assertNever Utility

Assigning to a never-typed local variable works, but it's noisy. A better approach is a small utility function that captures the pattern and makes the intent clear:

function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unhandled variant: ${JSON.stringify(value)}`);
}

This function does two things:

  1. Compile time: the value: never parameter means TypeScript will flag a type error if any variant reaches this call — if shape is still Ellipse, it's not assignable to never
  2. Runtime: it throws an error with a descriptive message if a value somehow reaches the default branch (e.g., data from an external source that doesn't match known variants)
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

Clean, self-documenting, and safe at both compile time and runtime. Define assertNever once in a shared utilities file and use it across your codebase.

Put assertNever in a Shared Utilities File

Define assertNever in a single file — src/utils/assert.ts or similar — and import it wherever you use discriminated unions. It's a foundational utility in any TypeScript codebase that takes exhaustiveness seriously. Some teams also add assertNever calls to their ESLint configuration to enforce exhaustive switches automatically.

Pattern Matching Helpers

Switch statements are statements — they don't return values directly. This means you can't use them inline in JSX, object literals, or expressions. A common workaround is a match helper that converts a union value into a result by calling the right handler:

function match<T extends { kind: string }, R>(
  value: T,
  handlers: { [K in T["kind"]]: (variant: Extract<T, { kind: K }>) => R }
): R {
  const handler = handlers[value.kind as T["kind"]];
  return (handler as (v: T) => R)(value);
}

This looks complex, but the usage is clean. TypeScript infers both the allowed keys and the correct variant type for each handler:

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

If you add ellipse to Shape and forget to add it to the handlers object, TypeScript reports a compile error: Property 'ellipse' is missing in type. You get exhaustiveness checking in expression position without needing a switch statement.

Libraries like ts-pattern provide a battle-tested, feature-rich version of this pattern — supporting nested matching, guards, wildcards, and more. If you find yourself writing complex match logic frequently, it's worth evaluating. But the simple match helper above covers the majority of real-world cases with zero dependencies.

Handling Nested Variants

When discriminated unions nest — a variant that itself contains another discriminated union — switch your way down each level, keeping narrowing at each step:

type NetworkError =
  | { kind: "timeout"; durationMs: number }
  | { kind: "unauthorized"; redirectTo: string }
  | { kind: "serverError"; statusCode: number };
 
type ApiResult<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: NetworkError };
 
function handleResult<T>(result: ApiResult<T>): string {
  switch (result.status) {
    case "success":
      return `OK: ${JSON.stringify(result.data)}`;
    case "error":
      // result.error is narrowed to NetworkError here
      switch (result.error.kind) {
        case "timeout":
          return `Timed out after ${result.error.durationMs}ms — retry?`;
        case "unauthorized":
          return `Auth required — redirect to ${result.error.redirectTo}`;
        case "serverError":
          return `Server error ${result.error.statusCode}`;
        default:
          return assertNever(result.error);
      }
    default:
      return assertNever(result);
  }
}

Each assertNever call guards its own level of the union. Add a new NetworkError variant and the inner assertNever fails to compile. Add a new ApiResult variant and the outer one does.

Real-World Examples

Discriminated union switches are the backbone of reducers in state management. Here's a typed Redux-style reducer:

type CounterAction =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }
  | { type: "set"; value: number };
 
type CounterState = { count: number; lastAction: string };
 
function counterReducer(
  state: CounterState,
  action: CounterAction
): CounterState {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.amount, lastAction: "increment" };
    case "decrement":
      return { count: state.count - action.amount, lastAction: "decrement" };
    case "reset":
      return { count: 0, lastAction: "reset" };
    case "set":
      return { count: action.value, lastAction: "set" };
    default:
      return assertNever(action);
  }
}

TypeScript ensures every action type is handled and every property access matches the action variant in that branch. The reducer is self-documenting: the type definition tells you exactly what actions exist, and the switch tells you exactly how each is handled.

Best Practices

Switch on the discriminant directly. Avoid switching on a derived value — switch(getKind(shape)) breaks TypeScript's ability to narrow the original type.

One concern per case. Keep each case block focused. If a case requires complex logic, extract it into a named function:

case "serverError":
  return handleServerError(result.error); // function returns string

Never fall through between cases. TypeScript narrows each case independently. Falling through with // intentional fallthrough confuses both readers and TypeScript's narrowing. If two cases share behaviour, extract a shared function and call it from both.

Return from every case instead of breaking. When your switch is inside a function that returns a value, returning from each case is cleaner than assigning to a variable and breaking:

// Prefer — return from each case
function label(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return "Circle";
    case "rectangle":
      return "Rectangle";
    case "triangle":
      return "Triangle";
    default:
      return assertNever(shape);
  }
}
 
// Avoid — mutable variable, harder to follow
function label(shape: Shape): string {
  let result: string;
  switch (shape.kind) {
    case "circle":
      result = "Circle";
      break;
    case "rectangle":
      result = "Rectangle";
      break;
    case "triangle":
      result = "Triangle";
      break;
    default:
      return assertNever(shape);
  }
  return result;
}

Watch for Implicit Fall-Through

JavaScript switch cases fall through to the next case unless you return, break, or throw. TypeScript doesn't warn about this by default, though ESLint's no-fallthrough rule does. Enable it in your ESLint config alongside TypeScript to catch accidental fall-through early.

Key Takeaways

  • TypeScript narrows the full discriminated union type inside each case block — property accesses are checked against the narrowed variant
  • A default: assertNever(value) branch provides compile-time and runtime exhaustiveness checking
  • The assertNever utility — (value: never): never => { throw new Error(...) } — is a one-liner worth defining in every TypeScript project
  • A match helper brings expression-style pattern matching to TypeScript without a dependency
  • For nested discriminated unions, apply assertNever at each level independently

Phase 4 Type Composition Complete

You've now covered the full arc of TypeScript's type composition tools: union types, intersection types, literal types, type guards, discriminated unions, and pattern matching. These six patterns work together — you'll use them in combination constantly. Next up: generics — how to write code that works across many types without sacrificing type safety.