Pattern Matching Everywhere
Function heads, case, receive, and binaries - how four BEAM languages turn pattern matching into the main control-flow tool, why Lua on the BEAM doesn't, and where Gleam's exhaustiveness checker changes the game.
Most languages you've used reach for pattern matching as a nicety - a switch with super-powers, bolted on late. On the BEAM it is the opposite: pattern matching is the spine of the language. You destructure data in function heads, in case, in receive, even inside binary parsers, and the same matching machinery underlies assignment itself. Learn to read the shapes and you can read the program.
This article walks the four matching surfaces - function heads, case, receive, and binaries - across Erlang, Elixir, Gleam, and LFE, then looks at the one BEAM language that can't do any of this (Luerl, i.e. Lua), and at Gleam's standout feature: a compiler that refuses to build if your match isn't exhaustive.
Matching is not assignment
Before the four surfaces, the one idea everything else rests on. In Erlang, = is the match operator, not assignment. X = 1 says "make the left side match the right side"; since X is unbound, it binds to 1. But the left side can be any pattern:
%% Destructure on the way in. These all succeed and bind variables.
{ok, Value} = {ok, 42}, %% Value = 42
[Head | Tail] = [1, 2, 3], %% Head = 1, Tail = [2,3]
#{name := Name} = #{name => "Joe"},%% Name = "Joe"
{X, X} = {7, 7}. %% same var twice: both must be 7
If a match can't succeed it raises a badmatch - failure is a first-class outcome, not an exception you forgot to plan for. {X, X} = {7, 8} crashes, and on the BEAM crashing is a legitimate strategy. Variables, once bound, are immutable, so X = 5, X = 6 is a failed match, not a reassignment.
Elixir keeps the exact same semantics but flips one default: because variables can be rebound, x = 6 after x = 5 silently rebinds. To match against a variable's existing value you pin it with ^:
{:ok, value} = {:ok, 42} # value = 42
x = 5
^x = 6 # ** (MatchError): 6 does not match 5
[first | _rest] = [1, 2, 3] # first = 1
%{name: name} = %{name: "José"} # name = "José"
LFE writes the same match as set or, more commonly, inside let, with patterns built from tuple, cons, lists, and literals (literals quoted with '):
(let (((tuple 'ok value) (tuple 'ok 42))) ; value => 42
(let (((cons head tail) (list 1 2 3))) ; head => 1, tail => (2 3)
(list value head tail)))
Gleam is the outlier even here. It is statically typed, so a partial pattern in a let - one that might not match every value of its type - is a compile error unless you opt in with let assert:
let pair = #(1, 2)
let #(a, b) = pair // fine: tuples always have this shape
// This pattern can fail (the list might be empty), so it won't compile
// as a plain `let`. You must acknowledge the risk:
let assert [first, ..rest] = [1, 2, 3] // first = 1, rest = [2, 3]
That single rule - the compiler tracks which patterns are total - is the thread that runs through everything Gleam does differently below.
Surface 1: matching in function heads
The BEAM's signature move is dispatching on the shape of the arguments. A function is several clauses; the runtime tries each head top-to-bottom and runs the first whose patterns match. This replaces most of the if-ladders you'd write elsewhere.
Erlang, computing a factorial and reporting an HTTP status, dispatches purely on argument structure:
fact(0) -> 1;
fact(N) when N > 0 -> N * fact(N - 1).
describe({ok, Body}) -> {200, Body};
describe({error, notfound}) -> {404, "not found"};
describe({error, _Reason}) -> {500, "server error"}.
The when N > 0 is a guard - a side-effect-free boolean test that further constrains a clause. Guards are limited on purpose (comparisons, type tests like is_integer/1, arithmetic), so they can never themselves crash the match.
Elixir is the same model with def:
def fact(0), do: 1
def fact(n) when n > 0, do: n * fact(n - 1)
def describe({:ok, body}), do: {200, body}
def describe({:error, :notfound}), do: {404, "not found"}
def describe({:error, _reason}), do: {500, "server error"}
LFE folds the clauses into one defun, each clause a parenthesised (pattern body), with guards introduced by (when ...):
(defun fact
((0) 1)
((n) (when (> n 0)) (* n (fact (- n 1)))))
(defun describe
(((tuple 'ok body)) (tuple 200 body))
(((tuple 'error 'notfound)) (tuple 404 "not found"))
(((tuple 'error _reason)) (tuple 500 "server error")))
Gleam does not have multi-clause function heads in this sense. A Gleam function has a single parameter list; you destructure inside the body with case. This is a deliberate trade: instead of spreading dispatch across clauses the compiler can't easily see all of at once, Gleam funnels every decision through one construct it can check for completeness.
pub fn describe(result: Result(String, Error)) -> #(Int, String) {
case result {
Ok(body) -> #(200, body)
Error(NotFound) -> #(404, "not found")
Error(_) -> #(500, "server error")
}
}
Surface 2: case
case brings function-head matching anywhere an expression is allowed. Same patterns, same guards, scrutinising a single value.
case lists:keyfind(port, 1, Config) of
{port, P} when is_integer(P) -> P;
{port, _} -> error(bad_port);
false -> 8080 %% default
end.
case Map.fetch(config, :port) do
{:ok, p} when is_integer(p) -> p
{:ok, _} -> raise "bad port"
:error -> 8080
end
(case (maps:find 'port config)
((tuple 'ok p) (when (is_integer p)) p)
((tuple 'ok _) (error 'bad_port))
('error 8080))
Gleam's case is the language's only branching construct - there is no separate if. It can match multiple subjects at once, supports | alternatives, .. rest patterns on lists, string-prefix patterns, and as to name a sub-match:
case x, y {
0, 0 -> "origin"
0, _ | _, 0 -> "on an axis" // alternative patterns
_, _ -> "somewhere else"
}
case message {
"GET " <> path -> route(path) // string prefix pattern
other -> log_unknown(other) // bind the whole value
}
case tokens {
[first, ..] as all -> keep(first, all) // `as` names the whole sub-match
[] -> []
}
And case carries guards too, with if:
case temperature {
t if t < 0 -> "freezing"
t if t < 30 -> "comfortable"
_ -> "hot"
}
Surface 3: receive
A process's mailbox is drained by receive, whose clauses are - you guessed it - patterns. The twist is selective receive: receive doesn't take the first message, it takes the first message that matches a clause. Non-matching messages stay in the mailbox, in order, for the next receive. Pattern matching is what makes the mailbox addressable.
loop(State) ->
receive
{set, Key, Value} ->
loop(maps:put(Key, Value, State));
{get, Key, From} ->
From ! {reply, maps:get(Key, State, undefined)},
loop(State);
stop ->
ok %% no recursion: the process ends
after 5000 ->
timeout %% nothing arrived in 5s
end.
The after clause is a timeout in milliseconds; after 0 means "check the mailbox but never block." Elixir spells receive almost identically, the clauses reading like a case:
def loop(state) do
receive do
{:set, key, value} -> loop(Map.put(state, key, value))
{:get, key, from} ->
send(from, {:reply, Map.get(state, key)})
loop(state)
:stop -> :ok
after
5_000 -> :timeout
end
end
LFE has receive as a special form, with the same after tail:
(defun loop (state)
(receive
((tuple 'set key value)
(loop (maps:put key value state)))
((tuple 'get key from)
(! from (tuple 'reply (maps:get key state 'undefined)))
(loop state))
('stop 'ok)
(after 5000 'timeout)))
Gleam is, again, the principled exception. Because mailboxes on the raw BEAM are untyped (any term can land in any process), a typed language can't expose receive directly without breaking its own guarantees. Instead Gleam wraps message-passing in typed Subjects and gleam/erlang/process.select: you build a selector that maps each expected message type into your own message type, and the type checker ensures you handled the shape. The pattern matching moves into a case over a value whose type the compiler controls, rather than over whatever happened to arrive:
import gleam/erlang/process
pub fn main() {
let subject = process.new_subject()
process.send(subject, "hello")
let selector =
process.new_selector()
|> process.selecting(subject, fn(msg) { msg })
// Blocks up to 5000ms for a message on `subject`; the result type
// is known at compile time, so the following code can rely on it.
let assert Ok(msg) = process.select(selector, 5000)
msg
}
The exact gleam_otp / gleam_erlang API has shifted across releases, so check your versions - but the constant is that the dynamic, "any term in the mailbox" surface is replaced by typed handles the compiler can reason about.
Surface 4: binaries and the bit syntax
Here the BEAM does something most languages can't: it pattern-matches inside the bytes. Erlang's bit syntax lets you describe a binary's layout - field sizes in bits, types, endianness - and bind each field, all in a pattern. Parsing a network packet becomes a single match. This is one of Erlang's original reasons for existing (it was built for telecom).
%% Parse an IPv4 header straight out of the wire.
<<Version:4, IHL:4, _TOS:8, TotalLen:16,
_Id:16, _Flags:3, _FragOff:13,
TTL:8, Proto:8, _Checksum:16,
Src:32, Dst:32, Rest/binary>> = Packet.
%% Length-prefixed framing: pull a 16-bit length, then exactly that
%% many bytes - the value bound earlier is reused later in the pattern.
<<Len:16, Payload:Len/binary, More/binary>> = Frame.
A field is Value:Size/Type-unit-signedness-endianness; Rest/binary soaks up "everything left." Crucially Payload:Len/binary uses Len - bound moments earlier in the same pattern - to decide how many bytes to take. That's parsing and matching fused into one expression.
Elixir wears the same power in <<>> syntax with slightly different spelling (binary, integer, :: for the spec):
<<version::4, ihl::4, _tos::8, total_len::16,
_id::16, _flags::3, _frag_off::13,
ttl::8, proto::8, _checksum::16,
src::32, dst::32, rest::binary>> = packet
# UTF-8 prefix matching and the rest as a binary
<<"GET ", path::binary>> = request
LFE expresses bit syntax as nested (binary ...) forms, each segment carrying its (size N) and type:
;; Pull a 16-bit colour value apart into 5/6/5-bit channels.
(let (((binary (r (size 5)) (g (size 6)) (b (size 5))) packet))
(list r g b))
;; Length-prefixed frame: 16-bit length, then that many bytes.
(let (((binary (len (size 16))
(payload (size len) binary)
(rest binary)) frame))
(tuple len payload rest))
Gleam has this too, as bit arrays with <<>> syntax and segment options like size, unit, big/little, and bits for the remainder - type-checked like everything else:
case packet {
<<version:size(4), ihl:size(4), _tos:size(8), total_len:size(16),
rest:bits>> -> handle(version, ihl, total_len, rest)
_ -> handle_malformed()
}
Bit-syntax matching is the BEAM family's quiet super-power: protocol parsers that would be loops of shifts and masks elsewhere collapse into one declarative pattern.
The one that can't: Lua on the BEAM
Luerl runs real Lua on the BEAM, and this is exactly where the family splits. Lua has no structural pattern matching at all - no function-head dispatch, no case/match, no destructuring of arbitrary shapes. It has if/elseif, and it has multiple assignment, which superficially resembles tuple destructuring but is just positional and never branches or fails:
-- Multiple assignment / multiple return values. This is NOT matching:
-- it can't dispatch, can't fail to match, can't bind on shape.
local function divmod(a, b)
return a // b, a % b
end
local q, r = divmod(17, 5) -- q = 3, r = 2
local x, y, z = 1, 2 -- z is nil; no error, no "non-match"
Lua does have something called "patterns," but they're a lightweight string-matching mini-language (string.match, string.gmatch, gsub) - closer to a cut-down regex than to structural matching. They match text, not data shapes:
-- Lua "patterns" are about strings, a completely different concept.
local key, value = string.match("port=8080", "(%w+)=(%w+)")
-- key = "port", value = "8080"
So when this site says "pattern matching everywhere," the everywhere is the four Erlang-lineage languages. Luerl faithfully reproduces Lua - which means it faithfully lacks the feature. Branching in Luerl is if-ladders and table lookups, exactly as in stock Lua. (This is a good reminder that "a BEAM language" describes the runtime, not the surface language: Luerl gives you Lua's semantics, including the absence of match.)
Gleam's exhaustiveness: the compiler has your back
The four Erlang-lineage languages share the same matching expressiveness. What sets Gleam apart is that its matching is checked for completeness at compile time. Every case must handle every possible value of the type it scrutinises. Miss one and the program doesn't compile.
pub type Shape {
Circle(radius: Float)
Square(side: Float)
Rectangle(width: Float, height: Float)
}
pub fn area(shape: Shape) -> Float {
case shape {
Circle(r) -> 3.14159 *. r *. r
Square(s) -> s *. s
// Forgetting Rectangle is a COMPILE ERROR:
// This case expression does not have a pattern for all possible values.
// The missing patterns are:
// Rectangle(width:, height:)
Rectangle(w, h) -> w *. h
}
}
The payoff shows up during refactoring. Add a fourth variant - Triangle - and the compiler immediately flags every case over Shape that now has a hole, pointing you at the exact lines to fix. Refactoring stops being archaeology.
In Erlang, Elixir, and LFE the same omission is silent. A case with no matching clause raises case_clause (or function_clause for a missing function head) - at runtime, when that value finally appears, possibly in production:
%% Erlang: forgetting the rectangle clause compiles cleanly.
%% It crashes only when a rectangle is actually passed in.
area(Shape) ->
case Shape of
{circle, R} -> 3.14159 * R * R;
{square, S} -> S * S
%% no {rectangle, _, _} clause -> ** exception error: no case clause
end.
You can ask Erlang's Dialyzer to look for some of these, but Dialyzer uses success typing: it is deliberately unsound, reporting only what it can prove wrong, so it stays quiet about many real gaps. Elixir's newer set-theoretic type checker (shipping incrementally since v1.17) is closing this distance by flagging some impossible and missing clauses - a genuinely important step - but it remains gradual and a moving target. Gleam's checker is the inverse: sound and on by default. If it can't prove your case is total, it refuses to build. The same guarantee covers the partial-let rule from the start of this article: any pattern that might not match is either rejected or must be written as let assert, making the risk visible at the call site.
Two consequences worth internalising:
- There is no "default that swallows bugs" by accident. A wildcard
_ ->in Gleam is a choice you can see, not the absence of a forgotten branch. - Adding data to a type is safe. New variants surface every place that needs updating. In the dynamic languages, the same change can leave latent
case_clausecrashes scattered across the codebase.
One idea, four spellings (and one absence)
| erlang | elixir | gleam | lfe | luerl (lua) | |
|---|---|---|---|---|---|
| Match operator | = (bind once) |
= (rebind; ^ to pin) |
let (must be total) |
set / let |
= is plain assignment |
| Function-head dispatch | yes | yes | no - use case |
yes | no |
case |
yes | yes | yes (only branch construct) | yes | no - if/elseif only |
receive |
yes | yes | typed Subject + selector |
yes | n/a |
| Binary / bit-syntax match | yes | yes | yes (bit arrays) | yes | no |
| Guards | when |
when |
if in case |
(when ...) |
n/a |
| Exhaustiveness | runtime case_clause |
runtime (+ partial gradual checks) | compile-time, enforced | runtime case_clause |
n/a |
Pattern matching is the thread that ties the Erlang lineage together: the same destructuring shows up in function heads, case, receive, and binaries, so once you can read one you can read them all. Erlang, Elixir, and LFE give you that expressiveness with runtime checking and the BEAM's "let it crash" safety net. Gleam keeps every bit of the expressiveness and adds a compiler that won't let a match be incomplete in the first place. And Luerl reminds us that "a BEAM language" is a statement about the runtime, not the syntax - Lua brings its own, matchless, model along for the ride.
Further reading
- Erlang bit syntax: https://www.erlang.org/doc/system/bit_syntax.html
- Elixir pattern matching guide: https://hexdocs.pm/elixir/pattern-matching.html
- Gleam language tour - case expressions and exhaustiveness: https://tour.gleam.run/
- LFE binaries and bitstrings: https://docs.lfe.io/current/user-guide/diving/3.html
- Lua string patterns (note: not structural matching): https://www.lua.org/manual/5.3/manual.html#6.4.1