174 lines
5.2 KiB
Markdown
174 lines
5.2 KiB
Markdown
# tiny-pattern-ts
|
|
|
|
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
|
|
|
|
## Synopsis
|
|
|
|
```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"
|
|
```
|
|
|
|
## 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.
|
|
|
|
## 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`:
|
|
|
|
```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
|
|
|
|
Yet to be implemented
|
|
|
|
## 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).
|