Pattern Matching: From Rust to TypeScript

publish: September 1, 2024

modified: September 27, 2024

Discover how pattern matching can revolutionize your code in both Rust and TypeScript. This deep dive explores powerful techniques for handling complex data structures, streamlining conditional logic, and boosting code maintainability across diverse programming challenges.

As a developer constantly seeking to refine my skills, I’ve found pattern matching to be a powerful technique, particularly prevalent in functional programming and languages like Rust. This post explores how we can bring similar functionality to TypeScript.

The Power of Pattern Matching

Pattern matching is a feature that allows you to match complex data structures against predefined patterns. It’s particularly useful when dealing with diverse data formats or complex conditional logic.

Consider a weather forecasting application that receives data in various formats (XML, JSON, plain text) from multiple providers. Pattern matching can simplify the extraction of essential information like temperature, wind speed, and humidity, making your code more readable and maintainable.

Rust’s Native Pattern Matching

Rust provides built-in pattern matching using 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 in Rust

One of Rust’s key strengths is exhaustive checking, which ensures all potential cases are handled:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

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

Omitting any TrafficLight variant would result in a compilation error, preventing potential bugs where a case is not handled. This is a fairly trivial example, but let’s look at a more complex use case:

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 complex example demonstrates several advanced features of Rust’s pattern matching:

  1. Enum matching with different variants
  2. Tuple destructuring (e.g., Message::Move(x, y))
  3. Guards with if conditions (e.g., if text.len() < 10)
  4. Matching on Option types
  5. Struct destructuring (e.g., User { id, name, active: true })
  6. Using the .. wildcard to ignore remaining fields

These features allow for concise and expressive handling of complex data structures and conditions.

TypeScript’s Approach

While TypeScript lacks native pattern matching, it offers alternatives:

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");
  }
}

Libraries like ts-pattern can enhance TypeScript’s pattern matching capabilities. Here’s a more complex example similar to the Rust one:

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();
}

This TypeScript example using ts-pattern demonstrates similar capabilities to the Rust version, including pattern matching on complex structures, guard conditions, and exhaustiveness checking.

Practical Use Cases

Pattern matching shines in several real-world scenarios:

  1. State Machines: Managing complex application states and transitions.
  2. Data Parsing: Handling various data formats in ETL (Extract, Transform, Load) processes.
  3. Error Handling: Providing detailed and context-specific error messages.
  4. Game Development: Managing different game states, events, or character behaviors.
  5. Compiler Design: Parsing and processing abstract syntax trees.

Conclusion

Pattern matching, whether native in Rust or simulated in TypeScript, offers powerful tools for creating cleaner, more efficient, and robust code. As you explore these techniques, consider how they can enhance your projects and coding style.