Skip to content

Pattern Matching

Scala-style pattern matching for TypeScript — structural patterns, extractors, exhaustiveness, and zero-cost compilation.

typescript
import { match } from "@typesugar/std";

const area = match(shape)
  .case({ kind: "circle", radius: r })
  .then(Math.PI * r ** 2)
  .case({ kind: "square", side: s })
  .then(s ** 2)
  .case({ kind: "rect", w, h })
  .then(w * h);
// Compile error if you miss a variant. Zero runtime overhead.

The Fluent API

Pattern matches use a .case().if().then() chain that works in any TypeScript file:

typescript
const y = match(x)
  .case([first, _, _])
  .if(first > 0)
  .then(first)
  .case([_, second, _])
  .then(second)
  .else(0);

Matches compile to optimized output with zero runtime overhead.


Pattern Catalogue

Literals

Match exact values — numbers, strings, booleans, null, undefined:

typescript
match(x)
  .case(42)
  .then("the answer")
  .case("hello")
  .then("greeting")
  .case(true)
  .then("yes")
  .case(null)
  .then("nothing")
  .else("other");

Compiles to: direct === comparisons. 7+ literal arms get a switch statement for V8 optimization.

Variable Binding

Bind the matched value to a name, optionally with a guard:

typescript
match(x)
  .case(n)
  .if(n > 0)
  .then(n * 2)
  .case(n)
  .then(-n);

Pattern variables don't need pre-declaration — the macro creates properly scoped bindings.

Compiles to:

typescript
const __m = x;
const n = __m;
if (n > 0) return n * 2;
return -n;

Wildcard (_)

Match anything without binding:

typescript
match(x)
  .case(_)
  .then("anything")
  // or, more commonly:
  .else("anything");

_ is never bound. .else(value) is syntactic sugar for a final _ => value case.

Array / Tuple Patterns

Destructure arrays by position, with optional rest:

typescript
match(arr)
  .case([])
  .then("empty")
  .case([x])
  .then(`singleton: ${x}`)
  .case([a, b])
  .then(`pair: ${a + b}`)
  .case([first, _, _])
  .if(first > 0)
  .then(first)
  .case([head, ...tail])
  .if(tail.length > 0)
  .then(`${head} + ${tail.length} more`)
  .else("other");

Pattern semantics:

PatternMatches
[]Exactly empty
[a, b]Exactly length 2
[a, _, _]Exactly length 3, only a bound
[head, ...tail]Length >= 1, rest captured

Compiles to:

typescript
if (Array.isArray(__m) && __m.length === 0) return "empty";
if (Array.isArray(__m) && __m.length === 1) {
  const x = __m[0];
  return `singleton: ${x}`;
}
// ... etc

Object Patterns

Match on property presence and values:

typescript
match(obj)
  .case({ a, b })
  .if(a > 0)
  .then(a + b)
  .case({ name: n, age })
  .if(age >= 18)
  .then(`Adult: ${n}`)
  .case({ name })
  .then(name)
  .case({ ...rest })
  .then(Object.keys(rest).length);

Pattern semantics:

PatternMeaning
{ a, b }Has props a and b (open match)
{ name: n }Has name, binds to n
{ kind: "circle" }Literal property check (no bind)
{ kind: "circle", r }Literal check + binding
{ ...rest }Bind all remaining properties

Discriminated Union Patterns

The most common pattern — match on a discriminant field:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; w: number; h: number };

// Exhaustive by default — compile error if a variant is missing
match(shape)
  .case({ kind: "circle", radius: r })
  .then(Math.PI * r ** 2)
  .case({ kind: "square", side: s })
  .then(s ** 2)
  .case({ kind: "rect", w, h })
  .then(w * h);

Compiles to: optimized switch on the discriminant field. No IIFE for small unions.

Type Patterns

Match on runtime type using constructor syntax:

typescript
match(value)
  .case(String(s))
  .then(`string: ${s}`)
  .case(Number(n))
  .if(n > 0)
  .then(`positive: ${n}`)
  .case(Number(n))
  .then(`number: ${n}`)
  .case(Array(a))
  .then(`array[${a.length}]`)
  .case(Date(d))
  .then(d.toISOString())
  .case(TypeError(e))
  .then(`type error: ${e.message}`)
  .else("unknown");

Type check mapping:

PatternRuntime Check
String(s)typeof __m === "string"
Number(n)typeof __m === "number"
Boolean(b)typeof __m === "boolean"
Array(a)Array.isArray(__m)
Date(d)__m instanceof Date
MyClass(v)__m instanceof MyClass
Some(v)Destructure typeclass (see below)

OR Patterns

Match multiple alternatives with the same handler:

typescript
// .or() chaining
match(status)
  .case(200)
  .or(201)
  .or(204)
  .then("success")
  .case(400)
  .or(401)
  .or(403)
  .or(404)
  .then("client error")
  .case(500)
  .or(502)
  .or(503)
  .then("server error")
  .case(code)
  .then(`status: ${code}`);

OR patterns bind no variables (same restriction as Scala).

Compiles to: (__m === 200 || __m === 201 || __m === 204) ? "success" : ...

AS Patterns (Bind Whole While Destructuring)

Bind the whole matched value to an alias alongside destructured bindings:

typescript
match(point)
  .case([x, y])
  .as(p)
  .if(x > 0 && y > 0)
  .then({ point: p, quadrant: 1 })
  .case([x, y])
  .as(p)
  .then({ point: p, quadrant: 0 });

Regex Patterns

Match strings against regular expressions and destructure capture groups:

typescript
match(str)
  .case(/^(\w+)@(\w+)\.(\w+)$/)
  .as([_, user, domain, tld])
  .then({ user, domain, tld })
  .case(/^https?:\/\/(.+)$/)
  .as([_, url])
  .then(fetch(url))
  .case(/^\d+$/)
  .as([num])
  .then(parseInt(num))
  .case(s)
  .then(s);

Compiles to:

typescript
{
  const __r = __m.match(/^(\w+)@(\w+)\.(\w+)$/);
  if (__r !== null) {
    const [_, user, domain, tld] = __r;
    return { user, domain, tld };
  }
}

Nested Patterns

Patterns compose at arbitrary depth:

typescript
match(data)
  .case({ user: { name, scores: [first, ...rest] } })
  .if(first > 90)
  .then(`${name} aced it with ${first}`)
  .case({ user: { name }, active: true })
  .then(`Active: ${name}`)
  .else("unknown");

Extractor Patterns (Destructure Typeclass)

Match using custom extractors via the Destructure typeclass — typesugar's equivalent of Scala's unapply:

typescript
import { match } from "@typesugar/std";
import { Some, None, Left, Right } from "@typesugar/fp";

// Option matching
match(option)
  .case(Some(v))
  .if(v > 0)
  .then(v * 2)
  .case(Some(v))
  .then(v)
  .case(None)
  .then(0);

// Either matching
match(either)
  .case(Left(err))
  .then(`Error: ${err}`)
  .case(Right(val))
  .if(val > 0)
  .then(`Positive: ${val}`)
  .case(Right(val))
  .then(`Value: ${val}`);

Built-in extractors:

ExtractorInputExtracts
Some(v)Option<T>T
NoneOption<T>(boolean match)
Left(v)Either<L, R>L
Right(v)Either<L, R>R
Ok(v)Result<T, E>T
Err(e)Result<T, E>E
Cons(h,t)List<T>[T, List<T>]
NilList<T>(boolean match)

Exhaustiveness

Every match() is always exhaustive — like Rust, not like Scala. Missing cases produce a compile error:

error[TS9401]: Non-exhaustive match — missing cases: "blue"
  --> src/colors.ts:5:1
   |
 5 | match(color)
   | ^^^^^ missing case "blue"
   |
   = help: Add .case("blue").then(...) or .else(...) to handle remaining cases

How It Works

Pattern DomainExhaustiveness Rule
Discriminated unionAll variants must be covered
Literal union ("a" | "b")All values must be covered
BooleanBoth true and false
Sum types (Destructure)All variant extractors present
string, number, arrays, etc.Requires _ or .else()
unknown / anyRequires _ or .else()

The .else() Escape Hatch

.else(value) satisfies exhaustiveness for any type. Use it when you only care about a few cases:

typescript
match(bigUnion)
  .case({ kind: "a" })
  .then(handleA())
  .case({ kind: "b" })
  .then(handleB())
  .else(undefined); // Explicitly: everything else is undefined

Runtime Safety Net

Even with compile-time exhaustiveness, the generated code includes a terminal throw:

typescript
throw new MatchError(__m); // "Non-exhaustive match: <value>"

MatchError extends Error and has a .value property with the unmatched value. This catches cases where the type is widened at runtime (e.g. via any or external data).


Custom Extractors (Destructure Typeclass)

Define your own extractors by providing a Destructure instance:

typescript
/** @impl Destructure<typeof Email, string, { user: string; domain: string }> */
const emailDestructure = {
  extract(input: string): { user: string; domain: string } | undefined {
    const m = input.match(/^([^@]+)@(.+)$/);
    return m ? { user: m[1], domain: m[2] } : undefined;
  },
};

// Now works in patterns:
match(str).case(Email({ user, domain })).then(`${user} at ${domain}`).else("not an email");

Auto-Derivation

For product types (interfaces, classes), Destructure auto-derives. You don't write anything:

typescript
interface Point {
  x: number;
  y: number;
}

// Auto-derived: extract(p) → [p.x, p.y]
match(point)
  .case(Point(x, y))
  .if(x > 0)
  .then(`positive x: ${x}`)
  .else("non-positive");

For sum types (discriminated unions), each variant gets its own extractor automatically.

Boolean Extractors

For patterns that test membership without extracting data:

typescript
/** @impl Destructure<typeof Even, number, true> */
const evenDestructure = {
  extract(input: number): true | undefined {
    return input % 2 === 0 ? true : undefined;
  },
};

match(n).case(Even).then("even").else("odd");

Optimization

The match macro compiles to the most efficient code possible.

Dead Arm Elimination

The macro uses the TypeScript type checker to prune impossible arms before generating any code:

typescript
const x: "ok" = getStatus();
match(x).case("ok").then(200).else(500);
// Compiles to just: 200
// The .else() arm is dead — x can only be "ok"

For unions, the type narrows after each arm:

typescript
const x: "ok" | "fail" = getStatus();
match(x).case("ok").then(200).case("fail").then(500);
// Compiles to: x === "ok" ? 200 : 500
// Second arm needs no check — after excluding "ok", only "fail" remains

Impossible patterns produce a compile error:

error[TS9402]: Pattern "pending" can never match type "ok" | "fail"

Code Generation Strategies

Pattern KindArms <= 6Arms > 6
All literalsTernary chainSwitch statement
DiscriminantTernary chainSwitch statement
Sparse integersTernary chainBinary search tree
Dense integersTernary chainSwitch (V8 jump table)
Mixed structuralSequential checksSequential checks

Unreachable Pattern Warnings

Arms dominated by earlier patterns produce warnings:

warning: Unreachable pattern — previous arm already covers this case

Migration: Old API → New Fluent API

The old match() with object handlers continues to work. The new fluent API adds structural patterns on top.

Before (object handler form)

typescript
import { match, when, otherwise, P } from "@typesugar/std";

// Discriminated union
const area = match(shape, {
  circle: ({ radius }) => Math.PI * radius ** 2,
  square: ({ side }) => side ** 2,
  _: () => 0,
});

// Guard-based matching
const category = match(age, [
  when(
    (n) => n < 13,
    () => "child"
  ),
  when(
    (n) => n < 18,
    () => "teen"
  ),
  otherwise(() => "adult"),
]);

// Array pattern helpers
const result = match(list, [
  when(P.empty, () => "empty"),
  when(P.length(1), ([x]) => `one: ${x}`),
  otherwise(() => "default"),
]);

After (fluent form)

typescript
import { match } from "@typesugar/std";

// Discriminated union — same expressiveness, exhaustive by default
const area = match(shape)
  .case({ kind: "circle", radius: r })
  .then(Math.PI * r ** 2)
  .case({ kind: "square", side: s })
  .then(s ** 2)
  .else(0);

// Guard-based — no separate when()/otherwise() needed
const category = match(age)
  .case(n)
  .if(n < 13)
  .then("child")
  .case(n)
  .if(n < 18)
  .then("teen")
  .else("adult");

// Array patterns — first-class, no P.* helpers needed
const result = match(list).case([]).then("empty").case([x]).then(`one: ${x}`).else("default");

What Changed

Old APINew Fluent APINotes
match(v, { ... })match(v).case(...).then(...)Object form removed
when(pred, handler).case(n).if(pred).then(...)when() removed
otherwise(handler).else(value)otherwise() removed
P.empty.case([])P.* removed
P.length(n).case([a, b, ...])Array patterns are first-class
isType("string").case(String(s))Constructor syntax replaces isType()

Migration

The old API (when(), otherwise(), P.*, isType(), and the object handler form) has been removed. Use the fluent API shown above for all pattern matching.


Full Example

Putting it all together — a real-world expression evaluator:

typescript
import { match } from "@typesugar/std";

type Expr =
  | { kind: "num"; value: number }
  | { kind: "add"; left: Expr; right: Expr }
  | { kind: "mul"; left: Expr; right: Expr }
  | { kind: "neg"; expr: Expr }
  | { kind: "var"; name: string };

function evaluate(expr: Expr, env: Record<string, number>): number {
  return match(expr)
    .case({ kind: "num", value: v })
    .then(v)
    .case({ kind: "add", left: l, right: r })
    .then(evaluate(l, env) + evaluate(r, env))
    .case({ kind: "mul", left: l, right: r })
    .then(evaluate(l, env) * evaluate(r, env))
    .case({ kind: "neg", expr: e })
    .then(-evaluate(e, env))
    .case({ kind: "var", name: n })
    .then(env[n] ?? 0);
  // No .else() needed — all 5 variants covered
}

IDE Experience

The fluent API uses compile-time macros, so your editor's TypeScript language service won't recognise pattern variables (r, s, w, h in the examples above) as declared bindings. You'll see red squiggles on those identifiers — this is expected. The macro rewrites them into valid JavaScript at build time.


Learn More

Released under the MIT License.