tmu 34567856a5 ♻️ Label arrange-act-assert blocks in specs
Rework every test body into // Arrange / // Act / // Assert blocks
separated by blank lines: the factory is arranged once, the matcher is
built from it in a single act, and all type/runtime checks sink to the
end. index.test.ts adopts the same shape.

Also migrate off expect-type's deprecated toMatchTypeOf: the checks
are assignability tests, so toExtend is the faithful replacement.
2026-09-16 16:00:41 +00:00
2026-09-08 20:47:22 +02:00
2026-09-10 22:49:01 +00:00
2026-09-16 14:52:30 +00:00
2026-09-16 16:00:41 +00:00
2026-09-14 12:37:58 +00:00
2026-09-06 00:13:12 +02:00
2026-09-16 09:01:49 +00:00
2026-02-02 13:38:42 +01:00
2025-04-29 13:43:07 +02:00
2026-09-16 14:52:21 +00:00
2026-09-15 21:46:41 +00:00

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 (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).

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.

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%