Pattern Matching
Normative source (v0.33.0). The
matchconstruct is specified at version 1.0.0 indocs/MATCH.md. That document covers grammar, the full pattern sub-grammar, static / dynamic / operational semantics, formatter conformance, the Maranget-style exhaustiveness algorithm, refinement narrowing, the diagnostic catalogue, and a soundness proof sketch. This page is the user-facing tutorial complement; for any conflict, the formal specification is the authority.
Pattern matching is the primary way Cure programs decompose data and
direct control flow. Every pattern is compiled down to Erlang abstract
forms by Cure.Compiler.PatternCompiler, type-checked against the
scrutinee, and analysed for exhaustiveness. Guards, pin equalities, and
repeated-variable equalities are injected as andalso guard chains, so
a pattern always succeeds-or-fails atomically.
This page is the authoritative user-facing reference for the language feature. The on-disk companion document docs/PATTERNS.md describes the AST-to-Erlang lowering in full.
Where patterns can appear
Patterns are not confined to match. The same grammar is accepted in
every one of these positions:
matchexpressions: arm heads and theirwhenguards.- Multi-clause function heads: each
| pat -> bodyclause. letbindings:let pat = exprdestructures immediately, with a compile-time failure if the pattern is not exhaustive for the declared scrutinee type.fnparameters: parameter positions accept the full pattern grammar, not just variable names.- Comprehension generators:
for pat <- sourceand the corresponding filter forms. try ... catch: thecatchclauses match on the raised value the same way amatchwould.
Each clause starts with a fresh scope, so names can be reused freely between clauses without shadowing warnings.
Literal patterns
Every literal form that Cure accepts in expression position is also a pattern. A literal pattern succeeds exactly when the scrutinee is structurally equal to the literal.
match n
0 -> :zero
1 -> :one
-1 -> :minus_one # unary minus is recognised in patterns
0xFF -> :byte
1_000 -> :big
Supported literal shapes:
- Integers (
42,0xFF,0b1010,1_000_000), with unary minus accepted as-42. - Floats (
3.14,0.001). - Strings (
"hello"), lowered to autf8binary pattern. Byte strings lower to a raw binary pattern. - Atoms (
:ok,:error,:my_atom). - Booleans (
true,false). nil.- Characters (
'a','Z').
Variables, wildcards, and repeated names
A bare identifier binds a fresh variable; the underscore is the wildcard and binds nothing.
match value
_ -> :anything
x -> do_something(x)
When a name occurs more than once in the same pattern, the compiler emits a synthetic equality guard: every occurrence must match the same value.
match pair
%[x, x] -> :equal
_ -> :different
The injected guard is conjoined with any user-written when clause
via andalso, so repeated variables compose cleanly with guards.
The pin operator ^x
^x compares against an already-bound variable instead of binding a
fresh one. It lowers to a fresh variable plus a synthetic equality
guard against the pre-existing binding.
let target = get_tag()
match event.tag
^target -> :hit
_ -> :miss
If target is not in scope at the pin position, the type checker
emits E024 (unbound pin variable) and the compiler degrades to a
plain binding.
Lists
Two cons forms are accepted in both pattern and construction position. Single-head cons matches the head and the tail:
match xs
[] -> :empty
[h | t] -> handle(h, t)
Multi-head cons desugars to right-associated cons cells. The pattern
below is identical to [a | [b | [c | rest]]]:
match xs
[a, b, c | rest] -> a + b + c
_ -> 0
Fixed-size list patterns without a tail also work:
match xs
[a, b] -> a + b
_ -> 0
Tuples
Tuple literals and patterns share the %[...] prefix.
match value
%[0, 0] -> :origin
%[x, y] -> move(x, y)
%[_, _, _] -> :three_elements
Tuple patterns recurse into every element, so arbitrary nesting works out of the box.
Maps
Map patterns use the %{...} prefix. Every key must be a literal;
the compiler lowers each field to an Erlang map_field_exact entry,
which means the key is required to be present in the scrutinee. Fields
not listed in the pattern are ignored (open matching).
match request
%{method: "GET", path: p} -> fetch(p)
%{method: m, path: _} -> reject(m)
A bare identifier at a map-key position is shorthand for key: key:
%{x, y} == %{x: x, y: y}
A non-literal, non-identifier map key triggers E023.
Records
Record patterns lower to a map pattern with the implicit
__struct__ := :tag guard plus one map_field_exact entry per named
field. They participate in schema-driven type checking: referencing a
field that does not exist emits E021, and supplying a sub-pattern
whose type does not unify with the declared field type emits E022.
rec Point
x: Int
y: Int
rec Person
name: String
address: Address
match p
Point{x: 0, y: 0} -> :origin
Person{name, address: Address{city}} -> greet(name, city)
A bare identifier inside a record pattern is the field-punning
shorthand: Person{name} expands to Person{name: name}. Unspecified
fields are matched open, so records can be extended without breaking
existing patterns.
ADT constructors
Constructors of algebraic data types lower to tagged tuples of the
form {:tuple, L, [tag_atom | child_forms]}. Any PascalCase name in
function-call position inside a pattern is treated as a constructor
pattern.
type Option(T) = Some(T) | None
type Result(T, E) = Ok(T) | Error(E)
match opt
Some(v) -> v
None() -> 0
Nullary constructors must use explicit empty parentheses. A bare
None on its own would bind a fresh variable, not match the nullary
constructor.
Constructor patterns recurse into their arguments as patterns, so nested ADTs decompose in a single arm:
match x
Some(Ok(v)) -> v
Some(Error(_)) -> -1
None() -> 0
Nested destructuring
Every shape above composes with every other. The classic stress test from the v0.18.0 release notes destructures a 3-tuple whose middle element is a map holding a cons list:
match value
%[_, %{list: [head | _]}, _] -> handle_head(head)
%[Ok(v), Error(_)] -> v
%[_, %{kind: "event", payload: p}, _] -> p
_ -> default
There is no imposed depth limit.
Guards
Guards restrict when a clause applies. They appear after when, both
in function heads and in match arm heads:
fn classify(x: Int) -> String
| x when x > 0 -> "positive"
| x when x < 0 -> "negative"
| _ -> "zero"
match event
Msg(s) when Std.String.length(s) > 0 -> s
Msg(_) -> "empty"
_ -> "other"
Guards accept the usual set of operators:
- Comparison:
==,!=,<,>,<=,>= - Boolean connectives:
and,or,not - Arithmetic:
+,-,* - Effect-free calls permitted by BEAM guard grammar
Synthetic guards injected by the compiler (pin equalities, repeated
variables) are conjoined with the user-written guard via andalso.
Bitstring patterns
Since v0.20.0, bitstring patterns accept the full Elixir-style segment
grammar. Segments inside <<...>> carry type, size, endianness,
signedness, and unit specifiers chained with -:
match packet
<<tag::utf8, size::16, payload::binary-size(size), rest::binary>> ->
decode(tag, payload, rest)
_ -> :malformed
The specifier grammar mirrors Erlang's exactly. Type atoms are
integer, float, bits, bitstring, bytes, binary, utf8,
utf16, utf32. Endianness (big / little / native),
signedness (signed / unsigned), and size/unit (size(n),
unit(u)) are optional and carry Erlang's defaults:
integer-unsigned-big-size(8)-unit(1). A bare integer after :: is
shorthand for size(n).
match bin
<<x::8>> -> x # same as <<x::size(8)>>
<<x::32-signed>> -> x # signed big-endian integer
<<x::float-little>> -> x # 64-bit little-endian float
Negated literals
Unary minus in a pattern position compiles to the negated literal, so
-5 matches the integer -5. This works for both integer and float
literals.
match temperature
-273 -> :absolute_zero
0 -> :freezing
n -> n
Exhaustiveness
The type checker runs two passes after every pattern-bearing construct:
- A flat classifier that recognises
:wildcard,:empty_list,:cons,{:literal, subtype, value},{:constructor, name, n},{:tuple, n},{:map, n}, and{:record, name, fields}at the top level of each arm. Missing shapes are reported asE004. - A Maranget-style nested pass that descends into tuple
scrutinees whose element types are enumerable (
Bool,Result(T, E),Option(T)). Missing witnesses are rendered as source-shape strings and reported asE025.
Warning: match expression has nested non-exhaustive cases (E025)
missing: %[Error(_), _]
Both passes emit :type_warning events via the pipeline. They do not
block compilation: you can still build the program, but the warnings
remain until the gap is closed.
For infinite types (Int, Float, String), a trailing wildcard _
is required for exhaustiveness; you cannot enumerate all integers.
Error codes
The pattern engine contributes the following dedicated error codes,
each available via cure explain Edd or cure why Edd:
- E004 - non-exhaustive patterns (flat classifier).
- E021 - unknown record field in a record pattern.
- E022 - record-pattern field type mismatch.
- E023 - non-literal, non-identifier map-pattern key.
- E024 - unbound pin variable.
- E025 - non-exhaustive nested match (Maranget walker).
Path-sensitive refinement
Pattern matches narrow the type of their scrutinee along each arm.
Cure.Types.PathRefinement threads the arm's implied constraints
back into the type environment so that subsequent expressions see a
more precise type.
if x != 0 then 100 / x else 0
Inside the then branch x is refined to {x: Int | x != 0}, so the
division is safe without an explicit refinement annotation.
Structural refinement narrowing
v0.20.0 ships Cure.Types.PatternRefinement, whose narrow/2 takes a
pattern AST and a scrutinee type and returns
{bindings, narrowed_scrutinee}. Two kinds of witnesses come back:
Literal-equality witnesses. A sub-pattern that is a literal means
the matched value is that literal along the arm. Matching 0
against Int narrows the scrutinee to {x: Int | x == 0}; inside a
tuple pattern the other slots keep their original element types.
Disjoint-tag witnesses. A constructor pattern (Ok(v),
Error(e)) or a map pattern with a literal :kind tag narrows the
scrutinee to a tagged variant:
narrow({:function_call, [name: "Ok"], [{:variable, [], "v"}]}, :any)
# => {%{"v" => :any}, {:variant, :ok, []}}
Every narrowed type is something the SMT translator already
understands, so PatternRefinement integrates directly with the
existing refinement machinery.
Worked example: JSON-shaped data
The example below uses only pattern matching -- no recursion helper, no conditional expression -- to classify a JSON-shaped value across five constructor variants, each with a different secondary shape:
type Json =
| JNull
| JBool(Bool)
| JInt(Int)
| JStr(String)
| JArr(List(Json))
| JObj(List(Tuple))
fn is_truthy(j: Json) -> Bool =
match j
JNull() -> false
JBool(b) -> b
JInt(0) -> false
JInt(_) -> true
JStr("") -> false
JStr(_) -> true
JArr([]) -> false
JArr(_) -> true
JObj([]) -> false
JObj(_) -> true
Every arm combines constructor destructuring with a literal-equality
witness (0, "", []) to decide truthiness without a single
conditional.
Limitations
A small set of pattern shapes are reserved for future versions:
- Range patterns (
1..10 -> ...) are compile-time rejected. - Bitstring segment specifiers beyond integer and variable tails were only partial before v0.19.0; the current parser accepts the full grammar, but a handful of Erlang-level segment combinations still fall through to the interpreter rather than the native compiler. They are accepted by the surface syntax and documented as experimental.
- Regex patterns are not part of the surface language; use
Std.Regexin expression position instead.
See examples/destructuring.cure, examples/json_tree.cure, and
examples/pattern_guards.cure for end-to-end programs that exercise
every shape on this page.
See also
- The
pickupconstruct -- the predicate-dispatch counterpart -- is documented at/pickupand specified normatively atdocs/PICKUP.md. - The full normative specification of
matchis atdocs/MATCH.md. Both specifications were published into HexDocs in v0.33.0. - The pattern-shape lowering tutorial lives in
docs/PATTERNS.md. - The binary-segment grammar lives in
docs/BINARIES.md.