· new features

Switch-True Narrowing in TypeScript: A Practical Alternative to Pattern Matching

TypeScript 5.3 introduces switch-true narrowing, a feature that simplifies complex if/else chains. By using switch-true narrowing, conditions can be expressed more declaratively, improving code readability and type safety.

One of the more subtle but powerful features added in TypeScript 5.3 is switch-true narrowing. While the name might not sound flashy, the technique lets us write cleaner control flow that feels a lot like pattern matching in languages such as Rust, Haskell, or Scala. In this post, we’ll explore what switch-true narrowing is, how it works, and why you might want to reach for it when your if/else chains start getting out of hand.

Contents

The Problem with if/else Spaghetti

We’ve all written logic that looks something like this:

main.ts
if (canSell && hasReachedTarget) {
  return sellForTargetPrice();
} else if (canSell && hasReachedStop && shouldSellForClosingPrice) {
  return sellForClosingPrice();
} else if (canSell && hasReachedStop) {
  return sellForBreakEvenPrice();
} else if (canSell && shouldTrailUp) {
  return trailUp();
} else if (canSell) {
  return notTakingSidewaysAction();
} else {
  return notTakingAnyAction();
}

This works fine, but quickly becomes noisy. Each condition reads like a rule, but visually the structure is buried inside nested if/else statements. Wouldn’t it be nice if we could express this as a set of rules in a more declarative way?

Enter Switch-True Narrowing

TypeScript 5.3 gives us exactly that with switch-true narrowing. Instead of switching on a variable, we switch on true and let each case be a condition:

main.ts
switch (true) {
  case canSell && hasReachedTarget:
    return sellForTargetPrice();
  case canSell && hasReachedStop && shouldSellForClosingPrice:
    return sellForClosingPrice();
  case canSell && hasReachedStop:
    return sellForBreakEvenPrice();
  case canSell && shouldTrailUp:
    return trailUp();
  case canSell:
    return notTakingSidewaysAction();
  default:
    return notTakingAnyAction();
}

At runtime, this works exactly as you’d expect: the first case that evaluates to true is executed. But at compile time, TypeScript now narrows types inside each branch based on the condition.

Here’s a simpler example:

main.ts
function handle(x: string | number | string[]) {
  switch (true) {
    case typeof x === 'string':
      // x: string
      return x.toUpperCase();
    case typeof x === 'number':
      // x: number
      return x.toFixed(2);
    case Array.isArray(x):
      // x: string[]
      return x.shift();
    default:
      // x: never
      return x;
  }
}

Each branch narrows x to the correct type but the code stays organized into a neat, declarative case-block.

Is This Pattern Matching?

Not quite. True pattern matching (as seen in Rust, Scala, or OCaml) works on the structure of data and often enforces exhaustiveness checks.

Switch-true narrowing in TypeScript is really just boolean guards with type narrowing. It doesn’t enforce covering all cases, and it doesn’t destructure objects for you.

That said, the ergonomics are very similar: you line up a series of rules, each with a condition and an action, and TypeScript guarantees the types inside each branch are safe.

If you need full structural pattern matching with exhaustiveness checks, try the excellent ts-pattern library.

Back to Blog