Typed pattern matching and algebraic data types for JavaScript and TypeScript — in Node and the browser, with zero dependencies.
import { match, P } from '@ossedb/patmat';
const fact = (n: number): number =>
match(n)
.with(0, () => 1)
.with(P.bind('n', P.number), ({ n }) => n * fact(n - 1))
.exhaustive();
fact(5); // 120Patterns are plain values: typos are compile-time errors, captures are typed, and TypeScript checks ADT matches for exhaustiveness.
npm install @ossedb/patmatThe package is ESM-only and environment-agnostic (no Node APIs), so it works anywhere modern JavaScript runs. Node ≥ 20.19 can also require() it from CommonJS. In the browser, use a bundler or a CDN:
<script type="module">
import { match, P } from 'https://esm.sh/@ossedb/patmat';
</script>match(value) starts a chain of clauses. The first pattern that matches wins; later clauses are not evaluated. End the chain with .otherwise(handler) or .exhaustive() (throws NonExhaustiveError if nothing matched).
const hello = (value: unknown): string =>
match(value)
.with(0, () => 'Ooops..')
.otherwise(() => 'Hello World!');
hello(0); // 'Ooops..'
hello(347); // 'Hello World!'Array patterns match tuples element-wise with exact length; a trailing P.rest relaxes the length and can capture the remainder. The classic head/tail recursion:
const sum = (xs: number[]): number =>
match(xs)
.with([], () => 0)
.with([P.bind('x'), P.rest('xs')], ({ x, xs }) => (x as number) + sum(xs as number[]))
.exhaustive();
sum([1, 4, 3]); // 8Plain-object patterns match structurally: every key in the pattern must match on the value, extra keys are allowed.
match(response)
.with({ status: 'ok', data: P.bind('data') }, ({ data }) => data)
.with({ status: 'error' }, () => null)
.otherwise(() => null);A clause can take a guard between the pattern and the handler. A failed guard falls through to the next clause.
const describeNumber = (n: number): string =>
match(n)
.with(
P.bind('n', P.number),
({ n }) => n % 2 === 0,
({ n }) => `${n} is even`,
)
.otherwise((n) => `${n} is odd`);
describeNumber(4); // '4 is even'
describeNumber(7); // '7 is odd'| Pattern | Matches |
|---|---|
0, 'a', true, null, undefined, 1n |
that literal, compared with Object.is |
[p1, p2] |
arrays of exactly that length, element-wise |
[p1, P.rest('xs')] |
arrays with at least the fixed elements; remainder captured |
{ key: p } |
objects where each pattern key matches (extra keys allowed) |
P._ |
anything (wildcard) |
P.number, P.string, P.boolean |
values of that primitive type |
P.nullish |
null or undefined |
P.bind(name) |
anything, capturing the value under name |
P.bind(name, pattern) |
the inner pattern, capturing the value with the inner pattern's type |
P.array() / P.array(element) |
any array / arrays whose every element matches |
P.range(lo, hi) |
numbers in the inclusive range |
P.when(predicate) |
values for which the predicate returns true |
P.union(...patterns) |
values matching any branch (or-pattern) |
P.instanceOf(Class) |
instances of Class |
const grade = (score: number): string =>
match(score)
.with(P.range(90, 100), () => 'A')
.with(P.range(80, 89), () => 'B')
.otherwise(() => 'C or below');
const kind = (value: unknown): string =>
match(value)
.with(P.union(P.string, P.number), () => 'text or number')
.with(P.instanceOf(Date), () => 'a date')
.otherwise(() => 'something else');isMatch(pattern, value) tests a pattern standalone, without handlers:
isMatch([P.number, P.rest()], [1, 2, 3]); // true
isMatch({ status: 'ok' }, { status: 'ok', data: [] }); // trueadt(name, spec) defines a variant type. Constructors come back on the returned object — nothing touches the global scope (unlike the 2014 API). Each spec entry is either a function from constructor arguments to the case's fields, or null for a nullary case, which becomes a frozen singleton value.
import { adt, type VariantOf } from '@ossedb/patmat';
const Shape = adt('Shape', {
Circle: (radius: number) => ({ radius }),
Rect: (w: number, h: number) => ({ w, h }),
});
type Shape = VariantOf<typeof Shape>;
const area = (s: Shape): number =>
Shape.match(s, {
Circle: ({ radius }) => Math.PI * radius ** 2,
Rect: ({ w, h }) => w * h,
});
area(Shape.Rect(2, 5)); // 10Instances are plain frozen records with a tag discriminant and flat fields ({ tag: 'Rect', w: 2, h: 5 }), so they serialize cleanly with JSON.stringify and match structurally in the general match:
match<Shape>(Shape.Circle(3))
.with({ tag: 'Circle', radius: P.bind('r', P.number) }, ({ r }) => `circle r=${r}`)
.otherwise(() => 'not a circle');Guards come for free: Shape.is(v) narrows to the union, Shape.Circle.is(v) to the case, and Shape.Circle.tag is the tag literal.
Shape.match is statically exhaustive: omitting a case is a TypeScript compile error. For a partial match, pass a _ fallback:
Shape.match(s, {
Circle: ({ radius }) => `circle of radius ${radius}`,
_: () => 'some other shape',
});The general match(...).exhaustive() is checked at runtime (it throws NonExhaustiveError, which carries the offending value). This split is deliberate: static exhaustiveness is sound and cheap for discriminated unions, while doing it for arbitrary patterns would require heavyweight type machinery.
Recursive types declare their variant union manually (the spec's fields need to refer to it — this is the documented limit of VariantOf inference):
import { adt, type Variant } from '@ossedb/patmat';
type List = Variant<'Empty'> | Variant<'Cons', { head: number; tail: List }>;
const List = adt('List', {
Empty: null,
Cons: (head: number, tail: List) => ({ head, tail }),
});
const sum = (l: List): number =>
List.match(l, {
Empty: () => 0,
Cons: ({ head, tail }) => head + sum(tail),
});
sum(List.Cons(1, List.Cons(2, List.Cons(3, List.Empty)))); // 6- First match wins. Clauses are tried in order; there is no redundancy analysis.
- Primitives use
Object.is. SoNaNmatchesNaN, and-0does not match0. - Object patterns are subsets. Extra keys on the value never fail a match.
- Array patterns are exact-length unless they end in
P.rest(...). - Other reference types are rejected as patterns. A
DateorMapused as a pattern throws aTypeError; match them withP.instanceOforP.when. (They are fine as values.) - Capture types come from the pattern, not the value.
P.bind('x')capturesunknown; writeP.bind('x', P.number)to capturenumber. Captures are not narrowed from the matched value's type, and clause patterns don't narrow the input type — this keeps the type-level machinery small and predictable. - Duplicate capture names: the last write wins.
Generated API docs (TypeDoc) live at the project's GitHub Pages site, or build them locally:
npm run docsnpm install
npm run lint # Biome
npm run typecheck # tsc
npm test # Vitest
npm run test:types # type-level tests
npm run build # tsdown -> dist/The published library supports Node >=20.19, but npm run build needs
Node >=22.18 (tsdown's floor).
Every example in this README runs as a test in test/readme.test.ts.