Elixir pattern matching, reproduced in TypeScript. Zero dependencies, ESM, type inference first.
npm install @izaias/patma # or: bun add @izaias/patmaimport { $, _, m, caseOf, clause, defn, withm, pin } from '@izaias/patma'Coming from TypeScript rather than Elixir? The cookbook starts from concrete situations — result tuples, reducers, request pipelines, error triage — instead of the Elixir mapping below.
| Elixir | patma |
|---|---|
pattern = value |
m(pattern, value) |
match?(pattern, value) |
isMatch(pattern, value) |
x (bind) |
$('x') |
_ / _name |
_ / _.name |
^x (pin) |
pin(x) |
[h | t] |
[$('h'), ...$('t')] |
%{key: x} |
{ key: $('x') } |
%User{name: n} |
struct(User, { name: $('n') }) |
pattern = pattern |
allOf(p1, p2) |
<<"pre", rest::binary>> |
/^pre(?<rest>.*)/ |
case |
caseOf |
def (multi-head) |
defn |
with |
withm |
when guards |
clause(pattern, guard, handler) |
Binding names and types are inferred from the pattern and the matched value — no annotations, no casts:
const { status, result } = m([$('status'), $('result')], ['ok', 42])
// ^ 'ok' ^ 42
const { head, tail } = m([$('head'), ...$('tail')], [1, 2, 3])
// ^ 1 ^ [2, 3]
const { name } = m({ name: $('name') }, { name: 'alice', age: 30 })
// ^ stringPatterns prune union members they can never match, so each caseOf clause sees
only the members it handles:
declare const result: ['ok', number] | ['error', string]
caseOf(
result,
clause(['ok', $('value')], ({ value }) => value + 1), // value: number
clause(['error', $('reason')], ({ reason }) => reason.length), // reason: string
)isMatch is a type guard — matching narrows the value, including plain unknown:
declare const result: ['ok', number] | ['error', string]
if (isMatch(['ok', _], result)) {
result // ['ok', number]
}m(pattern, value) asserts structure and returns the bindings (Elixir's = returns the value; bindings are the useful part here). No match raises MatchError.
{:ok, result} = {:ok, 42}
[head | tail] = [1, 2, 3]
%{name: name} = %{name: "alice", age: 30}const { result } = m(['ok', $('result')], ['ok', 42])
const { head, tail } = m([$('head'), ...$('tail')], [1, 2, 3])
const { name } = m({ name: $('name') }, { name: 'alice', age: 30 })Atoms map to strings, tuples and lists to arrays, maps to objects. Object patterns match a subset of keys, exactly like Elixir map patterns. Repeated names must match equal values: m([$('a'), $('a')], [1, 2]) fails.
tryMatch(pattern, value) returns the bindings or null — no throwing.
JS evaluates identifiers in patterns, so plain values are already "pinned" — ^x is the default here. pin() still matters for objects: a plain object pattern matches partially, a pinned one requires exact deep equality.
expected = %{a: 1}
^expected = %{a: 1, b: 2} # MatchErrorconst expected = { a: 1 }
isMatch(expected, { a: 1, b: 2 }) // true (map-style partial match)
isMatch(pin(expected), { a: 1, b: 2 }) // false (exact, like ^expected)case parse(input) do
{:ok, n} when n > 0 -> n
{:ok, _} -> 0
{:error, reason} -> raise reason
enddeclare function parse(input: string): ['ok', number] | ['error', string]
declare const input: string
caseOf(
parse(input),
clause(['ok', $('n')], ({ n }) => n > 0, ({ n }) => n),
clause(['ok', _], () => 0),
clause(['error', $('reason')], ({ reason }) => { throw new Error(reason) }),
)First matching clause wins; none matching raises CaseClauseError. Guards are plain functions on the bindings — a guard that throws just fails the clause, same as Elixir.
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)const factorial: (n: number) => number = defn(
clause([0], () => 1),
clause([$('n')], ({ n }) => n > 0, ({ n }) => n * factorial(n - 1)),
)Each clause pattern matches the argument list. No head matching raises FunctionClauseError. The annotation plays the role of Elixir's @spec: it types every head's bindings (n: number above) and the returned function. Without one, arguments and results are unknown.
with {:ok, user} <- fetch_user(id),
{:ok, email} <- fetch_email(user) do
"#{user}: #{email}"
else
{:error, reason} -> "failed: #{reason}"
enddeclare function fetchUser(id: number): ['ok', string] | ['error', string]
declare function fetchEmail(user: string): ['ok', string] | ['error', string]
declare const id: number
withm(
[['ok', $('user')], () => fetchUser(id)],
[['ok', $('email')], ({ user }) => fetchEmail(user)],
{
do: ({ user, email }) => `${user}: ${email}`,
else: clause(['error', $('reason')], ({ reason }) => `failed: ${reason}`),
},
)Bindings accumulate across steps with their inferred types; else clauses receive the union of every step's value, pruned per clause pattern. A step that fails to match short-circuits: with else clauses the value runs through them (miss raises WithClauseError); without, the value is returned as-is. A plain function as the last argument is shorthand for { do: fn }. To pin a binding from an earlier step, pass a function as the step pattern:
declare function issueToken(): string
declare function verify(): ['ok', string] | ['error', string]
withm(
[$('token'), () => issueToken()],
[({ token }) => ['ok', pin(token)], () => verify()],
({ token }) => token,
)Regex patterns stand in for binary matching: the value must be a string that matches, and named capture groups become bindings (typed Record<string, string> — group names cannot be read from a RegExp type).
<<"https://", host::binary>> = urldeclare const url: string
const { host } = m(/^https:\/\/(?<host>.+)/, url)- Rest patterns work anywhere in an array, not just tail position:
[$('first'), ...$('mid'), $('last')]. Mappatterns matchMapvalues (partial, key equality is SameValueZero);Datepatterns match by timestamp.struct(Class, fields?)=instanceofcheck plus field match, with fields typed from the instance.- Literal comparison is SameValueZero, so
NaNmatchesNaN. - In plain JavaScript,
$.nameproperty access still creates binders; the call form$('name')is what carries the binding name into the type system, so TypeScript only exposes that one. - Inferred types describe the success case. Unions are pruned per pattern; matching against
unknowntypes bindings by the pattern's shape. caseOfkeeps precise per-clause result types up to 12 clauses,withmup to 6 steps; beyond that they still work withunknownresults. Mixed result types acrosselseclauses of onewithmneed a shared annotation.- Not covered: improper lists, bit-level binary syntax, guards inside
withmsteps.
bun run check # banned tokens, tsgo + TS5 typecheck, tests, docs blocks, knip
bun run torture # check + property/differential tests, snapshots, type fuzzing, package validation, mutation testing
bun run build # emit dist/ (JS + declarations)The runtime matcher and the type-level matcher are two interpreters of one grammar; the test suite exists mostly to prove they agree. AGENTS.md describes the invariants. Code blocks in this README and in docs/ are extracted and compiled as part of check.