Pattern Matching
Scala-style pattern matching for TypeScript — structural patterns, extractors, exhaustiveness, and zero-cost compilation.
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:
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:
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:
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:
const __m = x;
const n = __m;
if (n > 0) return n * 2;
return -n;Wildcard (_)
Match anything without binding:
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:
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:
| Pattern | Matches |
|---|---|
[] | Exactly empty |
[a, b] | Exactly length 2 |
[a, _, _] | Exactly length 3, only a bound |
[head, ...tail] | Length >= 1, rest captured |
Compiles to:
if (Array.isArray(__m) && __m.length === 0) return "empty";
if (Array.isArray(__m) && __m.length === 1) {
const x = __m[0];
return `singleton: ${x}`;
}
// ... etcObject Patterns
Match on property presence and values:
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:
| Pattern | Meaning |
|---|---|
{ 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:
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:
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:
| Pattern | Runtime 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:
// .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:
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:
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:
{
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:
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:
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:
| Extractor | Input | Extracts |
|---|---|---|
Some(v) | Option<T> | T |
None | Option<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>] |
Nil | List<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 casesHow It Works
| Pattern Domain | Exhaustiveness Rule |
|---|---|
| Discriminated union | All variants must be covered |
Literal union ("a" | "b") | All values must be covered |
| Boolean | Both true and false |
| Sum types (Destructure) | All variant extractors present |
string, number, arrays, etc. | Requires _ or .else() |
unknown / any | Requires _ or .else() |
The .else() Escape Hatch
.else(value) satisfies exhaustiveness for any type. Use it when you only care about a few cases:
match(bigUnion)
.case({ kind: "a" })
.then(handleA())
.case({ kind: "b" })
.then(handleB())
.else(undefined); // Explicitly: everything else is undefinedRuntime Safety Net
Even with compile-time exhaustiveness, the generated code includes a terminal throw:
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:
/** @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:
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:
/** @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:
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:
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" remainsImpossible patterns produce a compile error:
error[TS9402]: Pattern "pending" can never match type "ok" | "fail"Code Generation Strategies
| Pattern Kind | Arms <= 6 | Arms > 6 |
|---|---|---|
| All literals | Ternary chain | Switch statement |
| Discriminant | Ternary chain | Switch statement |
| Sparse integers | Ternary chain | Binary search tree |
| Dense integers | Ternary chain | Switch (V8 jump table) |
| Mixed structural | Sequential checks | Sequential checks |
Unreachable Pattern Warnings
Arms dominated by earlier patterns produce warnings:
warning: Unreachable pattern — previous arm already covers this caseMigration: 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)
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)
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 API | New Fluent API | Notes |
|---|---|---|
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:
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
- PEP-008: Pattern Matching — Full spec with compilation details
- API Reference
- Package README
