Add a `compat` job that type-checks the whole suite and a consumer fixture against the minimum supported TypeScript (5.9), reusing the `dist/` artifact `build` produced and gating `publish`. The compiler is resolved by npx, so it never enters `devDependencies` or the local `check`/`verify` loop. The fixture imports the package by name, resolving the emitted declarations through the `exports` map; `expectTypeOf` / `.not.toBeAny()` make it reject an `any`-typed declaration, which a bare compile would accept. Correct the README consumer floor from >= 5.0 to >= 5.9 (set by `type-fest`) and drop the `node10` resolution claim, which the exports-only entry never satisfied.
341 lines
12 KiB
Markdown
341 lines
12 KiB
Markdown
# 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<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](./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<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"`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
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](#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.
|
|
|
|
```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<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.
|
|
|
|
```ts
|
|
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](#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).
|