Files
tiny-pattern-ts/README.md
T
tmu 9faa0ecc55 📝 Rewrite README as the user entry point
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.
2026-09-15 13:27:15 +00:00

279 lines
7.8 KiB
Markdown

# tiny-pattern-ts
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
## Synopsis
```sh
npm install tiny-pattern-ts
```
```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](./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`:
```ts
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](./development/library.md#exhaustive-is-a-runtime-check)).
Use `.otherwise(...)` when a fallback is wanted:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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)`
```ts
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)`
```ts
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()`
```ts
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)`
```ts
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)`
```ts
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)`
```ts
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)`
```ts
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)`
```ts
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?)`
```ts
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
```ts
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](./LICENSE).
## Contributing
Contributions are documented in [CONTRIBUTING.md](./CONTRIBUTING.md); the
reasons behind the project's decisions, rejected alternatives, and known issues
live in [development/](./development/README.md). AI coding agents start at
[AGENTS.md](./AGENTS.md).