Files
tmu 75807c4bd8 👷 Type-check the TS floor in CI
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.
2026-09-29 20:51:41 +00:00

12 KiB

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 (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/ and 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:

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, 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.

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 the effect ecosystem
  • 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 otherwise fallback

plain switch (baseline)

  • is the zero-dependency baseline — pair it with 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 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.