Follows the Perl/CPAN order: name + one-line description, Synopsis, Description, Examples, API reference, License. Tooling, CI and decision content moved to development/; the README keeps only user-facing material and a pointer to CONTRIBUTING.md and AGENTS.md. No version line and no package.json link.
tiny-pattern-ts
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
Synopsis
npm install tiny-pattern-ts
import { match, P } from "tiny-pattern-ts";
const reply = (answer: "yes" | "no") =>
match(answer)
.with(P.literal("yes"), (): "agreed" => "agreed")
.with(P.literal("no"), (): "declined" => "declined")
.exhaustive();
reply("yes"); // "agreed"
Requirements
- Node.js >= 26 (
enginesfield; pinned via.node-version). - TypeScript >= 5.0 to consume the published declarations. The emitted
.d.tsuseconsttype parameters (TS 5.0) and keep their relative.tsspecifiers; both resolve on TS >= 5.0 innode10/node16/nodenext/bundler. - The package is ESM-only (no CommonJS shim).
Description
tiny-pattern-ts gives TypeScript the shape of F#-style pattern matching:
a value flows through a chain of patterns, the first one that matches runs its
handler, and the handler receives the value narrowed to that pattern's type. The
"patterns" are ordinary objects whose matches method is a TypeScript type
guard, so narrowing composes the way any other guard does.
It is deliberately not a regex engine and not a macro. There is no transpiler
and no DSL to learn: match(value) returns a builder, .with(pattern, handler)
adds a case, and the chain ends in either .exhaustive() or .otherwise(...).
The type-level contract is the feature — see
development/library.md for the design decisions and
the known limitations.
Examples
Literal matching and exhaustive()
.exhaustive() returns the union of the handler return types and throws if no
case matched. Annotate handler returns when you want literal types rather than
string:
type Answer = "yes" | "no";
const reply = (answer: Answer): "agreed" | "declined" =>
match(answer)
.with(P.literal("yes"), (): "agreed" => "agreed")
.with(P.literal("no"), (): "declined" => "declined")
.exhaustive();
reply("yes"); // "agreed"
exhaustive() checks at runtime, not at compile time — TypeScript does not force
every union member to have a case (see
development/library.md).
Use .otherwise(...) when a fallback is wanted:
const label = (answer: Answer): string =>
match(answer)
.with(P.literal("yes"), () => "agreed")
.otherwise(() => "not agreed");
Matching by typeof
P.type<T>(name) pairs an explicit type T with the runtime typeof name it
should test for:
const describe = (value: unknown): string =>
match(value)
.with(P.type<string>("string"), (s) => `string of length ${s.length}`)
.with(P.type<number>("number"), (n) => `number ${n.toFixed(2)}`)
.otherwise(() => "something else");
The supported names are string, number, boolean, bigint, symbol,
undefined, object, and function. "object" matches non-null objects and
functions; "undefined" compares against undefined directly.
Structural matching and discriminated unions
P.shape(shape, refine?) checks that every key in shape exists on the value.
A value that is itself a matcher is applied, otherwise it is compared with
strict equality. To narrow to a concrete type, pass a refine type guard:
interface Circle {
readonly kind: "circle";
readonly radius: number;
}
interface Square {
readonly kind: "square";
readonly side: number;
}
type Shape = Circle | Square;
const area = (shape: Shape): number =>
match(shape)
.with(
P.shape({ kind: "circle" }, (v): v is Circle => "radius" in v),
(c) => Math.PI * c.radius ** 2,
)
.with(
P.shape({ kind: "square" }, (v): v is Square => "side" in v),
(s) => s.side ** 2,
)
.exhaustive();
Without refine, P.shape returns a matcher for the shape's own type, not the
narrowed one. Nested matchers can be used in the shape object, for example
P.shape({ name: P.type<string>("string") }).
Custom guards with when
P.when takes a type guard and infers the narrowed type from it:
const toNumber = (value: unknown): number =>
match(value)
.with(
P.when((v): v is string => typeof v === "string"),
(s) => Number.parseInt(s, 10),
)
.otherwise(() => 0);
Widening with any
P.any<T>(predicate) takes a plain boolean predicate and a declared type T,
for cases where the predicate cannot be written as a type guard:
const firstNumber = (items: readonly unknown[]): number | undefined =>
match(items)
.with(
P.any<readonly number[]>(
(v) =>
Array.isArray(v) &&
v.every((item) => typeof item === "number"),
),
(xs) => xs[0],
)
.otherwise(() => undefined);
API
match(value)
const match: <T>(value: T) => MatchBuilder<T, never>;
Starts a matching chain for value. The builder is immutable: every .with
returns a new builder, so a partially built chain can be reused.
.with(pattern, handler)
with<U extends T, V>(pattern: Matcher<U>, handler: (value: U) => V): MatchBuilder<T, R | V>;
Adds a case. handler receives the value narrowed to U, and its return type
V is added to the builder's result union R. A pattern whose narrowed type is
not assignable to the matched value's type is a compile error.
.exhaustive()
exhaustive(): R;
Returns the result of the first matching case. Throws
tiny-pattern-ts: match.exhaustive() called with no matching case if none
matched. It does not statically prove that every union member is covered.
.otherwise(handler)
otherwise(handler: (value: T) => R): R;
Like a final catch-all case: runs handler if no earlier case matched. Unlike
.exhaustive(), it never throws.
P.literal(value)
const P.literal: <const L extends string | number | boolean | null | undefined>(
value: L,
) => Matcher<L>;
Matches a single literal with === and narrows to its literal type.
P.type(type)
const P.type: <T>(
type: "string" | "number" | "boolean" | "bigint" | "symbol" | "undefined" | "object" | "function",
) => Matcher<T>;
Matches a typeof result and narrows to the explicitly supplied T. T is not
inferred from the name, so the type parameter and the runtime name must agree.
P.when(predicate)
const P.when: <T>(predicate: (value: unknown) => value is T) => Matcher<T>;
Wraps a type guard as a matcher. This is the constructor to prefer when you can express the check as a guard.
P.any(predicate)
const P.any: <T>(predicate: (value: unknown) => boolean) => Matcher<T>;
Wraps a boolean predicate and declares the narrowed type T yourself. Use it
only when a type guard is not expressible; prefer P.when.
P.shape(shape, refine?)
const P.shape: <S extends object, T extends S>(
shape: S,
refine?: (value: S) => value is T,
) => Matcher<T>;
Matches an object that has every key of shape. A shape value that is a
Matcher is applied; otherwise the value is compared with ===. Pass refine
to narrow to T; without it, the matched type is S.
Types
interface Matcher<T> {
readonly matches: (value: unknown) => value is T;
}
type Pattern<T> = Matcher<T>;
Every pattern constructor returns a Matcher<T>. Pattern<T> is an alias kept
for readability.
License
MIT © 2025 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.