Oscar te Giffel®
← Writing

Pattern Matching: From Rust to TypeScript.

How pattern matching works in Rust, what TypeScript gives us natively, and how ts-pattern bridges the gap.

cover

I’ve been spending a lot of time with Rust, and pattern matching is one of those features that makes you feel like other languages are missing something fundamental. This post is about bringing that same thinking to TypeScript.

What Pattern Matching Actually Gets You

Pattern matching lets you match complex data structures against predefined patterns and destructure them in one step. It’s especially useful when you’re dealing with different data shapes or complex conditional logic. Which, if you’re building anything real, is basically always.

Rust’s Native Pattern Matching

Rust has this built in with the match keyword:

enum Weather {
    Sunny,
    Cloudy,
    Rainy
}

fn print_weather(weather: Weather) {
    match weather {
        Weather::Sunny => println!("It's a sunny day!"),
        Weather::Cloudy => println!("It's a cloudy day."),
        Weather::Rainy => println!("It's raining outside."),
    }
}

Exhaustive Checking

One of Rust’s best features here is exhaustive checking. The compiler makes sure you handle every case:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

fn action_based_on_light(light: TrafficLight) {
    match light {
        TrafficLight::Red => stop(),
        TrafficLight::Yellow => slow_down(),
        TrafficLight::Green => go(),
    }
}

Leave out a variant and the code won’t compile. That’s trivial in this example, but look at something more complex:

enum Message {
    Quit,
    Move(i32, i32),
    Write(String),
    ChangeColor(i32, i32, i32),
    Login(String, Option<String>),
}

struct User { id: u32, name: String, active: bool }

fn process_message(msg: Message, user: Option<User>) {
    match msg {
        Message::Quit => println!("Quitting"),
        Message::Move(x, y) => println!("Moving to ({}, {})", x, y),
        Message::Write(text) if text.len() < 10 => println!("Short: {}", text),
        Message::Write(text) => println!("Long: {:.10}...", text),
        Message::ChangeColor(r, g, b) => println!("Color: RGB({},{},{})", r, g, b),
        Message::Login(name, Some(pass)) if pass.len() >= 8 => println!("{} logged in", name),
        Message::Login(name, _) => println!("Login failed for {}", name),
    }

    match user {
        Some(User { id, name, active: true }) => println!("Active: {} (ID: {})", name, id),
        Some(User { name, .. }) => println!("Inactive: {}", name),
        None => println!("No user logged in"),
    }
}

This packs a lot into a small space: enum matching, tuple destructuring, guards with if, Option matching, struct destructuring, the .. wildcard. All of it type-checked, all of it exhaustive.

TypeScript’s Version

TypeScript doesn’t have native pattern matching, but discriminated unions + switch gets you part of the way:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "rectangle"; width: number; height: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      throw new Error("Invalid shape");
  }
}

For anything more complex, ts-pattern is great. Here’s the same Message example:

import { match, P } from "ts-pattern";

type Message =
  | { type: "Quit" }
  | { type: "Move"; x: number; y: number }
  | { type: "Write"; text: string }
  | { type: "ChangeColor"; r: number; g: number; b: number }
  | { type: "Login"; name: string; password?: string };

type User = { id: number; name: string; active: boolean };

function processMessage(msg: Message, user: User | null) {
  match(msg)
    .with({ type: "Quit" }, () => console.log("Quitting"))
    .with({ type: "Move", x: P.number, y: P.number }, ({ x, y }) => console.log(`Moving to (${x}, ${y})`))
    .with({ type: "Write", text: P.when((t) => t.length < 10) }, ({ text }) => console.log(`Short: ${text}`))
    .with({ type: "Write" }, ({ text }) => console.log(`Long: ${text.slice(0, 10)}...`))
    .with({ type: "ChangeColor", r: P.number, g: P.number, b: P.number }, ({ r, g, b }) =>
      console.log(`Color: RGB(${r},${g},${b})`),
    )
    .with({ type: "Login", name: P.string, password: P.when((p) => p && p.length >= 8) }, ({ name }) =>
      console.log(`${name} logged in`),
    )
    .with({ type: "Login" }, ({ name }) => console.log(`Login failed for ${name}`))
    .exhaustive();

  match(user)
    .with({ active: true }, ({ id, name }) => console.log(`Active: ${name} (ID: ${id})`))
    .with({ active: false }, ({ name }) => console.log(`Inactive: ${name}`))
    .with(null, () => console.log("No user logged in"))
    .exhaustive();
}

Guards, exhaustiveness, destructuring. All there. Not as clean as Rust’s native syntax, but the .exhaustive() call gives you the same compile-time safety.

Where This Shines

  • State machines: managing complex application states and transitions
  • Data parsing: handling different formats in ETL processes
  • Error handling: detailed, context-specific error messages
  • Game dev: different game states, events, character behaviors
  • Compilers: parsing and processing ASTs

If you’re not using pattern matching yet, try it on your next switch statement. You’ll feel the difference.