# tiny-pattern-ts Exhaustive, type-safe pattern matching for TypeScript. ## Synopsis ```ts 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()("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](./development/library.md) for the design decisions and [Caveats](#caveats) for the limits. ## Installation ```sh npm install tiny-pattern-ts ``` ## Requirements - **Node.js >= 26** (`engines` field; pinned via `.node-version`). - **TypeScript >= 5.9** to consume the published declarations. The floor is set by the `type-fest` types the declarations use and is checked in CI against a consumer fixture; see [`compat/`](./compat) and [development/ci.md § TypeScript compatibility](./development/ci.md#typescript-compatibility). The emitted `.d.ts` keep their relative `.ts` specifiers, which resolve under `node16` / `nodenext` / `bundler`. The package exposes only an `exports` map (no `main` / top-level `types`), so the legacy `node10` resolver 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: ```ts import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; type ResultCode = "ok" | "created" | "no-content"; const toStatus = getPrimitiveUnionMatcher()({ 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"`: ```ts import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; type Status = "active" | "beta" | "deprecated" | "gateway-timeout"; const rollout = getPrimitiveUnionMatcher()( { 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: ```ts import { getTaggedUnionMatcher } from "tiny-pattern-ts"; interface Invoice { readonly currency: "eur" | "usd" | "jpy"; readonly amount: number; } const matchCurrency = getTaggedUnionMatcher()("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`: ```ts import { getPrimitiveUnionMatcherW } from "tiny-pattern-ts"; type Field = "name" | "tags" | "note"; const parse = getPrimitiveUnionMatcherW()({ 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()` | the value is the union and all handlers return the same type | one common `R` | | `getPrimitiveUnionMatcherW()` | the value is the union and handlers return different types | union of the handlers | | `getTaggedUnionMatcher()` | the value is a discriminated object and all handlers return the same type | one common `R` | | `getTaggedUnionMatcherW()` | 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](#examples) do this, so the builder is allocated once. ### Primitive-union matchers `getPrimitiveUnionMatcher()` 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. ```ts 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: ```ts 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()` 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. ```ts import { getTaggedUnionMatcher } from "tiny-pattern-ts"; type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number }; const matchShape = getTaggedUnionMatcher()("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](#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](./development/library.md). ## Caveats - **Only finite universes are supported.** The factory must be given a finite union of literals; `string`, `number` and template literals are rejected. This is what lets the exhaustive overload be proven, so the runtime `dispatch` throw 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`). - **`symbol` and `bigint` are not supported.** A `symbol` brand is a compile-time phantom with nothing to match at runtime, and a `bigint` is not a valid property key; neither satisfies the matcher's universe constraint. - **`NaN` and `-0` cannot be matched specifically.** They have no literal type, so both stay part of `number`. ### 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](./development/library.md#supported-universes). ## Alternatives ### [`ts-pattern`](https://github.com/gvergnaud/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`](https://effect.website/docs/code-style/pattern-matching/) - is the same piped matcher (`Match.type` / `Match.when` / `Match.exhaustive`), but only as part of the `effect` ecosystem - tiny-pattern-ts is standalone: no runtime dependency to buy into ### [`match-iz`](https://github.com/shuckster/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 `otherwise` fallback ### plain `switch` (baseline) - is the zero-dependency baseline — pair it with [`eslint-plugin-strict-pattern-matching`](https://www.npmjs.com/package/eslint-plugin-strict-pattern-matching) for exhaustiveness - is a statement, not an expression, so it cannot produce a value directly - leaves the `never` guard to you; tiny-pattern-ts is an expression and does not need one The [TC39 pattern-matching proposal](https://github.com/tc39/proposal-pattern-matching) is still stage 1, so userland libraries remain the only option today. ## License MIT © 2026 tmu. See [LICENSE](./LICENSE). ## Contributing Contributions are documented in [CONTRIBUTING.md](./CONTRIBUTING.md); the reasons behind the project's decisions, rejected alternatives, and known issues live in [development/](./development/README.md). AI coding agents start at [AGENTS.md](./AGENTS.md).