tmu fe02317fc8 📝 Track code-fence testing and the finish/push tension
Adds a Documentation task to validate Markdown code fences against src/, and a
Workflow task for the create:finish / create:branch upstream mismatch recorded
in development/workflow.md.
2026-09-15 13:40:04 +00:00
2026-09-08 20:47:22 +02:00
2026-09-10 22:49:01 +00:00
2026-09-14 12:37:58 +00:00
2026-09-06 00:13:12 +02:00
2026-09-15 13:39:58 +00:00
2026-09-15 09:33:17 +00:00
2026-02-02 13:38:42 +01:00
2025-04-29 13:43:07 +02:00
2026-09-15 09:33:17 +00:00
2026-09-15 09:33:17 +00:00
2026-09-15 13:27:15 +00:00

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 (engines field; pinned via .node-version).
  • TypeScript >= 5.0 to consume the published declarations. The emitted .d.ts use const type parameters (TS 5.0) and keep their relative .ts specifiers; both resolve on TS >= 5.0 in node10 / 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.

S
Description
No description provided
Readme MIT
1.5 MiB
0 Stars 1 Watchers 0 Forks
0.9.0
Latest
2026-09-29 23:57:54 +02:00
Languages
TypeScript 88.4%
Shell 9.9%
Dockerfile 1.7%