Skip to content

Repository files navigation

patma

Elixir pattern matching, reproduced in TypeScript. Zero dependencies, ESM, type inference first.

npm install @izaias/patma   # or: bun add @izaias/patma
import { $, _, 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)

Type inference

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 })
//      ^ string

Patterns 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]
}

The match operator

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.

Pin

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}  # MatchError
const 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

case parse(input) do
  {:ok, n} when n > 0 -> n
  {:ok, _} -> 0
  {:error, reason} -> raise reason
end
declare 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.

Multi-head functions

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

with {:ok, user} <- fetch_user(id),
     {:ok, email} <- fetch_email(user) do
  "#{user}: #{email}"
else
  {:error, reason} -> "failed: #{reason}"
end
declare 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,
)

Strings

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>> = url
declare const url: string

const { host } = m(/^https:\/\/(?<host>.+)/, url)

Extras and divergences

  • Rest patterns work anywhere in an array, not just tail position: [$('first'), ...$('mid'), $('last')].
  • Map patterns match Map values (partial, key equality is SameValueZero); Date patterns match by timestamp.
  • struct(Class, fields?) = instanceof check plus field match, with fields typed from the instance.
  • Literal comparison is SameValueZero, so NaN matches NaN.
  • In plain JavaScript, $.name property 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 unknown types bindings by the pattern's shape.
  • caseOf keeps precise per-clause result types up to 12 clauses, withm up to 6 steps; beyond that they still work with unknown results. Mixed result types across else clauses of one withm need a shared annotation.
  • Not covered: improper lists, bit-level binary syntax, guards inside withm steps.

Development

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.

About

Elixir's pattern matching system rebuilt in Typescript

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages