Cure v0.18.0 :: Deep Destructuring—pattern matching grows up
by Aleksei Matiushkin
For seventeen releases Cure’s pattern matching was a polite
demonstration. You could write match xs { [h | t] -> ... }. You
could write match opt { Some(v) -> v; None() -> 0 }. But the moment
the shape got interesting—a tuple whose middle slot was a map
whose :list field was a cons, or a record pattern whose field was
itself a record pattern—the compiler quietly lost interest and
handed back whatever it could. Map patterns in particular were
miscompiled: they emitted the Erlang construction form (K => V)
instead of the match form (K := V), so a map pattern accepted any
subject. We had tests pointing squarely at the broken behaviour; what
we did not have was a pattern compiler willing to recurse.
v0.18.0 replaces the pattern layer wholesale.
The headline
This now works, end to end:
match value
%[_, %{list: [head | tail]}, _] -> handle(head, tail)
Person{name, address: Address{city}} -> greet(name, city)
[Ok(v) | _] -> v
_ -> default
Every sub-pattern in that snippet is compiled as a real pattern. The
nested map patterns use exact matching, so they actually require the
key. The record patterns check the struct tag and each field. The
cons pattern binds v through the Ok(...) constructor. The
wildcard covers the rest.
A dedicated pattern compiler
The first architectural move is to separate pattern compilation from expression compilation. Pattern AST nodes look like expression AST nodes (both are MetaAST 3-tuples), but they have fundamentally different semantics: an expression is evaluated, a pattern is matched against a subject and binds variables as a side effect. Treating them uniformly was the original sin.
The new Cure.Compiler.PatternCompiler has a single compile/2
entry point and dispatches on the pattern shape:
{:tuple, _, elems},{:list, _, elems},{:list, [cons: true], [h, t]}recurse throughdo_compile/2for every child. No fall-through to expression codegen.{:map, _, pairs}emitsmap_field_exactforms ({:map_field_exact, L, Kform, Vform}), not the construction-formmap_field_assoc. This is the one-line fix that makes map patterns actually work.{:function_call, [record: true, name: T], fields}lowers to a map pattern with__struct__ := :tplus one exact entry per field. Unspecified fields are open-matched, soPerson{name}matches any person regardless of their other fields.{:function_call, [name: Tag], args}with a PascalCase tag lowers to a tagged tuple, recursing into each argument as a pattern.- Bare identifiers inside a record pattern are field-punning
shorthand:
Point{x, y}desugars toPoint{x: x, y: y}. {:pin, _, [{:variable, _, name}]}lowers to a fresh variable plus a guardV_fresh =:= V_name. The pin operator is the escape hatch for "compare against this already-bound value".- Repeated occurrences of the same variable in one pattern follow the
same rule: the first occurrence binds, later occurrences become
equality guards.
%[x, x]now matches exactly the pairs where both slots are equal.
Everywhere the codegen used to call compile_pattern/2 --
compile_multi_clause_function, compile_pattern_match,
compile_assignment, compile_comprehension,
compile_catch_and_finally—now delegates to the new module and
folds the synthetic pattern guards into the clause guard using
andalso.
The type checker catches up
Cure.Types.Checker.bind_pattern_vars/3 was the other half of the
old story: even when the codegen did the right thing, the type
checker gave every nested pattern variable the type :any, so
match p { Person{age: a} when a > 17 -> ... } never actually knew
a : Int.
The rewrite threads the scrutinee type through every pattern shape:
- Tuple patterns zip element-wise against the tuple element types.
- List and cons patterns bind the head to the list element type and the tail to the list type itself.
- Map patterns look up each key in the scrutinee (if the scrutinee is a known record, through its schema; otherwise through the map value type).
- Record patterns resolve every field against the schema registered
at
rectime. Unknown fields emit a warning under codeE021.
The practical effect is that every variable bound by a nested pattern
now carries the tightest type the structure allows, and subsequent
refinement checks fire against that type instead of against :any.
Nested exhaustiveness
v0.11.0 added a flat exhaustiveness pass: classify each arm as
:wildcard | :empty_list | :cons | {:literal, ...} | {:constructor, ...} | {:tuple, n},
union them against the scrutinee type, warn if something is missing.
That pass stays, because it is fast and covers the common case.
v0.18.0 adds a second pass: a best-effort Maranget-style column
walker (Cure.Types.PatternChecker.check_nested/2) that descends
into tuple scrutinees whose element types are enumerable. It collects
the set of inhabited constructors per column, computes the missing
ones, and emits concrete source-shaped witnesses:
Warning: match expression has nested non-exhaustive cases (E025)
missing: %[Error(_), _]
The witness is the pattern you would have to write to cover the gap.
Five new error codes
E021—E025 in Cure.Compiler.Errors:
- E021 Unknown record field in pattern.
- E022 Record pattern field type mismatch.
- E023 Non-literal map pattern key.
- E024 Unbound pin variable.
- E025 Non-exhaustive nested match.
All surface through cure explain E0xx. The catalog is now at 25
codes.
Field punning in both directions
Point{x, y} is shorthand for Point{x: x, y: y} in pattern
position and in construction position—the parser change is
agnostic. The saving shows up most in constructors that immediately
forward their parameters:
fn make_point(x: Int, y: Int) -> Point = Point{x, y}
For record patterns the saving is the same shape, but the win is larger because patterns tend to have many fields and the punning eliminates the repetition.
The pin operator
Cure’s lambda-style pattern matching always treated a variable as a fresh binding. That matches Erlang’s default and is usually what you want, but it makes "compare against this value" awkward:
# Pre-v0.18: you had to do this
let target = get_tag()
match event.tag
t when t == target -> :hit
_ -> :miss
With the pin operator:
# v0.18.0
let target = get_tag()
match event.tag
^target -> :hit
_ -> :miss
No runtime cost: the compiler lowers ^target to a fresh variable
plus the same equality guard you would have written by hand.
A stdlib module that exists to dogfood the engine
Std.Match is a brand-new, tiny stdlib module whose whole purpose is
to exercise the pattern engine:
fn first_two(list: List(T), default: T) -> Tuple =
match list
[h1 | t1] ->
match t1
[h2 | rest] -> %[h1, h2, rest]
[] -> %[h1, default, []]
[] -> %[default, default, []]
Eight such helpers (unpack_pair/1, fst/1, snd/1, head_tail/2,
uncons/1, first_two/2, unwrap_ok/2, unwrap_some/2) are now in
lib/std/match.cure, each implemented with nested patterns. They
double as integration tests: if the pattern engine regresses, this
module stops compiling.
Std.List.uncons/1 and Std.List.split_first/2 were also added, for
the same reason.
Numbers
- 1 new Elixir module (
Cure.Compiler.PatternCompiler, ~480 LOC). - Rewrites inside
Codegen,Checker, andPatternChecker. - 1 new stdlib module (
Std.Match); 21 total. - 2 new examples (
examples/destructuring.cure,examples/json_tree.cure) + extension ofpattern_guards.cure. - 2 new docs (
docs/PATTERNS.md, a new Chapter 4 indocs/TUTORIAL.md). - New error codes
E021--E025. - 923 tests (3 doctests, 920 tests). 26 new, 0 regressions.
- Zero credo issues in strict mode.
mix cure.check.stdlib: 21/21 clean.mix cure.check.examples: 26/26 clean.
Upgrade notes
v0.18.0 is source-compatible with v0.17.0 for well-formed programs. Two caveats:
-
Map patterns that used to accidentally succeed now have to be correct. If your code relied on the old (broken) map-pattern behaviour—for instance, a map pattern whose key was not present in the subject but which the compiler accepted anyway—you will now get a runtime
badmatchor a compile-time non-exhaustive warning. Fix the pattern. -
NonevsNone(). BareNonewas always a variable binding;None()is the nullary constructor. The new type checker now emits:unknown_record_fieldwarnings for the cases where you meantNone()but wroteNoneinside a record pattern. No automatic migration is provided; the fix is mechanical.
What’s next—v0.19.0 Bring the Furniture
v0.18.0 deliberately did one thing: deep destructuring. The previously-slated v0.18.0 items land in v0.19.0:
proofcontainers for laws-as-programs.assert_typebuiltin.- Records with default field values.
@derive(Show, Eq, Ord)fully wired through codegen.- Property-based testing in
Std.TestviaStd.Gen+forall/2. Std.Iterlazy iterator protocol.- First half of the package registry—version-constraint parser,
resolver, deeper
Cure.locksemantics. - Mutual-recursion totality.
- Pin operator promoted to default (it lives behind
--experimental-pinin v0.18.0). - Multi-head cons patterns
[a, b | rest]and full bitstring pattern specifiers.
Getting started
git clone https://github.com/am-kantox/cure-lang.git
cd cure
mix deps.get && mix test
mix escript.build
./cure version # Cure 0.18.0
./cure run examples/destructuring.cure
./cure run examples/json_tree.cure
The repository is at github.com/am-kantox/cure-lang. Pattern matching, at last, actually matches.