tiny-pattern-ts
Exhaustive, type-safe pattern matching for TypeScript.
Synopsis
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
// 1. We have a union type
type Contact =
| { kind: "email"; address: string }
| { kind: "phone"; number: string }
| { kind: "messenger"; username: string };
// 2. Create a matcher providing the discriminant property
const matchContact = getTaggedUnionMatcher<Contact>()("kind");
// 3. Define handlers for each branch of the union
const formatContact = matchContact({
email: (e) => `MAIL: ${e.address}`,
phone: (p) => `PHONE: ${p.number}`,
messenger: (m) => `MESSENGER: @${m.username}`,
});
// 4. Call the matcher with a value
const mailOutput = formatContact({ kind: "email", address: "ada@example.com" });
assert.equal(mailOutput, "MAIL: ada@example.com");
const phoneOutput = formatContact({ kind: "phone", number: "+1 555 0100" });
assert.equal(phoneOutput, "PHONE: +1 555 0100");
Description
tiny-pattern-ts is a pattern-matching library for TypeScript.
The main goal of tiny-pattern-ts is to make pattern matching type-safe with a
lean syntax. This is accomplished by being exhaustive and passing typed
parameters per branch to the handlers — supported by an outstanding
autocomplete and a tiny footprint. The matchers are data last and pipe-friendly:
build the handler map once, then apply the resulting matcher to values
(match(value), or pipe(value, match)).
See development/library.md for the design decisions and Caveats for the limits.
Installation
npm install tiny-pattern-ts
Requirements
- Node.js >= 26 (
enginesfield; pinned via.node-version). - TypeScript >= 5.9 to consume the published declarations. The floor is set
by the
type-festtypes the declarations use and is checked in CI against a consumer fixture; seecompat/and development/ci.md § TypeScript compatibility. The emitted.d.tskeep their relative.tsspecifiers, which resolve undernode16/nodenext/bundler. The package exposes only anexportsmap (nomain/ top-leveltypes), so the legacynode10resolver does not apply. - The package is ESM-only (no CommonJS shim).
Examples
A few real-world recipes. Each binds the handler-map function once and reuses it, so the matcher is allocated a single time.
Dispatch on a primitive union
A result code is itself a finite union, so getPrimitiveUnionMatcher keys a
handler on each member:
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
type ResultCode = "ok" | "created" | "no-content";
const toStatus = getPrimitiveUnionMatcher<ResultCode>()({
ok: () => 200,
created: () => 201,
"no-content": () => 204,
});
assert.equal(toStatus("ok"), 200);
assert.equal(toStatus("created"), 201);
assert.equal(toStatus("no-content"), 204);
Leave cases to a fallback
Pass a fallback as the second argument to handle only part of the universe; it
receives the members the map leaves uncovered — here the parameter is
"deprecated" | "gateway-timeout":
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
type Status = "active" | "beta" | "deprecated" | "gateway-timeout";
const rollout = getPrimitiveUnionMatcher<Status>()(
{ active: () => "enabled", beta: () => "enabled" },
(status) => `blocked (${status})`,
);
assert.equal(rollout("active"), "enabled");
assert.equal(rollout("deprecated"), "blocked (deprecated)");
Dispatch on a property union
The value does not have to be the union itself. When a single property carries a finite union, the tagged-union matcher keys on it and narrows the whole record to the selected value:
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
interface Invoice {
readonly currency: "eur" | "usd" | "jpy";
readonly amount: number;
}
const matchCurrency = getTaggedUnionMatcher<Invoice>()("currency");
const symbolOf = matchCurrency({
eur: (i) => `€${i.amount.toFixed(2)}`,
usd: (i) => `$${i.amount.toFixed(2)}`,
jpy: (i) => `¥${i.amount.toFixed(0)}`,
});
assert.equal(symbolOf({ currency: "usd", amount: 12.5 }), "$12.50");
assert.equal(symbolOf({ currency: "jpy", amount: 900 }), "¥900");
Widen the return type
When the handlers return different types, reach for the widening W variant: the
matcher's return is their union rather than one common type — here
string | string[] | undefined:
import { getPrimitiveUnionMatcherW } from "tiny-pattern-ts";
type Field = "name" | "tags" | "note";
const parse = getPrimitiveUnionMatcherW<Field>()({
name: () => "Ada",
tags: () => ["admin", "beta"],
note: () => undefined,
});
assert.equal(parse("name"), "Ada");
assert.deepEqual(parse("tags"), ["admin", "beta"]);
assert.equal(parse("note"), undefined);
API
The package exports four factories. Two axes pick one:
- Universe — a primitive-union matcher matches a value that is itself a
finite union (
"yes" | "no"); a tagged-union matcher matches an object discriminated by a property ({ kind: … }). - Return — the strict variant gives every handler one common return type
R; the widening variant (W) widens the return value to the union of the handler returns.
| Factory | Use when | Return |
|---|---|---|
getPrimitiveUnionMatcher<T>() |
the value is the union and all handlers return the same type | one common R |
getPrimitiveUnionMatcherW<T>() |
the value is the union and handlers return different types | union of the handlers |
getTaggedUnionMatcher<T>() |
the value is a discriminated object and all handlers return the same type | one common R |
getTaggedUnionMatcherW<T>() |
the value is a discriminated object and handlers return different types | union of the handlers |
Bind the function that takes the handler map to a match… variable once and
reuse it; the Examples do this, so the builder is allocated once.
Primitive-union matchers
getPrimitiveUnionMatcher<T>() takes the finite universe T and returns a
builder. Calling the builder with a handler map keyed by T's members returns a
matcher: a function from T to the common return type.
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
const reply = matchAnswer({
yes: () => "agreed",
no: () => "declined",
});
const answer = reply("yes");
assert.equal(answer, "agreed");
Add a fallback as the second argument to leave members unhandled; the fallback receives the remainder:
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">();
const label = matchLabel(
{ yes: () => "agreed", no: () => "declined" },
(other) => `not sure: ${other}`, // other: "maybe"
);
const answer = label("yes");
assert.equal(answer, "agreed");
const fallback = label("maybe");
assert.equal(fallback, "not sure: maybe");
getPrimitiveUnionMatcherW is the same builder, but the matcher's return type
is the union of the handler return types rather than one common R.
Tagged-union matchers
getTaggedUnionMatcher<T>() takes a discriminated union T. The returned
function takes the discriminant property's name and returns the handler-map
builder, keyed by that property's tags.
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
type Shape =
{ kind: "circle"; radius: number } | { kind: "square"; side: number };
const matchShape = getTaggedUnionMatcher<Shape>()("kind");
const area = matchShape({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const circleArea = area({ kind: "circle", radius: 2 });
assert.equal(circleArea, Math.PI * 4);
const squareArea = area({ kind: "square", side: 3 });
assert.equal(squareArea, 9);
getTaggedUnionMatcherW is the widening counterpart, exactly as in the
primitive-union pair. The discriminant key is restricted to properties whose
values are tags; see Caveats for the supported tags and the
boolean / null / undefined key projection.
The universe
The primitive universe T must be a finite union of literals with no
value/stringification collision. A broad member (string, number, a template
literal) or a colliding pair (true | "true", 1 | "1") is rejected at the
factory. The reasons and the rejected alternatives are in
development/library.md.
Caveats
- Only finite universes are supported. The factory must be given a finite
union of literals;
string,numberand template literals are rejected. This is what lets the exhaustive overload be proven, so the runtimedispatchthrow stays unreachable through the typed API. - A value and its stringification must not both be present. Object keys
stringify, so a universe containing both a member and the string it
stringifies to —
1 | "1",true | "true",null | "null"— is rejected at the factory. Either form alone is fine, and one value's string form may coexist with a different value's bare form ("true" | false). symbolandbigintare not supported. Asymbolbrand is a compile-time phantom with nothing to match at runtime, and abigintis not a valid property key; neither satisfies the matcher's universe constraint.NaNand-0cannot be matched specifically. They have no literal type, so both stay part ofnumber.
Why open universes are rejected
An open universe — one carrying a broad member, as in
type Units = "s" | "ms" | "min" | (string & {}) — is not a dispatch concern.
If the values arrive from outside the program, parse them at the boundary down
to a finite union and match the narrowed result; the openness never reaches the
matcher. If the domain is genuinely extensible, the right shape is a runtime
Map of handlers, where "no handler" is a lookup, not a pattern. Either way an
open matcher would abandon the one guarantee this library exists to give —
provable exhaustiveness — to automate what a switch and a default arm already
cover. The type-level cost of supporting open universes is recorded in
development/library.md.
Alternatives
ts-pattern
- is the full structural matcher — nested and partial patterns, guards, unions,
captures — exhausting at
.exhaustive() - has a fluent
.with(…)chain that is heavy on syntax; tiny-pattern-ts is one handler map - is about 2 kB minified and gzipped; tiny-pattern-ts is 0.2 kB
- reach for it when you need a feature tiny-pattern-ts does not cover
Effect's Match
- is the same piped matcher (
Match.type/Match.when/Match.exhaustive), but only as part of theeffectecosystem - tiny-pattern-ts is standalone: no runtime dependency to buy into
match-iz
- expresses patterns in the TC39 proposal's style, deciding each case at runtime
- is written in JavaScript with hand-maintained declarations, so its types do not prove the cases exhaustive
- tiny-pattern-ts does: exhaustiveness is a compile-time guarantee, not an
otherwisefallback
plain switch (baseline)
- is the zero-dependency baseline — pair it with
eslint-plugin-strict-pattern-matchingfor exhaustiveness - is a statement, not an expression, so it cannot produce a value directly
- leaves the
neverguard to you; tiny-pattern-ts is an expression and does not need one
The TC39 pattern-matching proposal is still stage 1, so userland libraries remain the only option today.
License
MIT © 2026 tmu. See LICENSE.
Contributing
Contributions are documented in CONTRIBUTING.md; the reasons behind the project's decisions, rejected alternatives, and known issues live in development/. AI coding agents start at AGENTS.md.