getPrimitiveUnionMatcherPartialW was declared with the intended P-inferring signature but assigned via `as any`, hiding that the declared parameter was not assignable to the implementation's. The union in PatternPrimitiveUnionPartial makes the `_` arm demand `_ ∈ keyof P`, which the exhaustive arm cannot prove. Intersect the inference hook Simplify<P> with the implementation's parameter shape, R pinned to PatternReturns<P>, so the assignment type-checks while callers keep inferring P from the argument. Add a spec for the exhaustive (no `_`) pattern.
tiny-pattern-ts
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
Synopsis
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"
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.
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).
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
Yet to be implemented
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.