Dynamic vs Static Typing on the BEAM
The BEAM was born dynamically typed - so how do Erlang's Dialyzer, Elixir's new gradual set-theoretic types, and Gleam's sound static system each catch type errors, and what does each one promise (or refuse to promise)?
The Erlang virtual machine - the BEAM - grew up dynamically typed. A value carries its type with it at runtime, functions accept whatever term you hand them, and the "let it crash" philosophy treats a type mismatch as just another fault for a supervisor to recover from. Erlang, Elixir, and the Lisp-flavoured LFE all live in this world; Luerl inherits Lua's dynamic typing too. Gleam is the outlier - a sound, statically typed language on the same VM.
But "dynamic vs static" on the BEAM is more interesting than a binary. Erlang ships Dialyzer, a static analyzer built on success typings. Elixir is rolling out a gradual set-theoretic type system baked into the compiler. And Gleam offers full sound static typing with inference. Each makes a different bargain about what it will catch, what it will miss, and what it asks of you. This article walks the spectrum.
What "dynamically typed" actually means here
A common confusion: dynamic typing is not weak typing. The BEAM is dynamically and strongly typed. Types are checked - rigorously - but at runtime, and the VM never silently coerces an integer into a string the way a weakly typed language might.
%% Erlang: types are checked at runtime, and checked strictly.
1 + 1. %% => 2
1 + "1". %% ** exception error: an arithmetic expression error (no silent coercion)
list_to_integer("oops"). %% ** exception error: badarg
# Elixir: same VM, same strong-but-dynamic semantics.
1 + 1 # => 2
1 + "1" # ** (ArithmeticError) bad argument in arithmetic expression
String.upcase(:not_a_string) # ** (FunctionClauseError)
Nothing about the value's type is in doubt at runtime; what's in doubt is whether a given call site will ever be reached with a term of the wrong shape. Dynamic typing defers that question to execution. Static typing answers it before the program runs. Everything below is about who answers, and when.
Erlang and Elixir: dynamic by birth
Both languages lean on the same trio that makes dynamic typing livable on the BEAM: pattern matching, tagged tuples, and supervision. The convention for "a result that might fail" is a tagged tuple the caller pattern-matches:
%% Erlang: {ok, V} | {error, Reason} is a convention, not a checked type.
read_config(Path) ->
case file:read_file(Path) of
{ok, Bin} -> {ok, parse(Bin)};
{error, Reason} -> {error, Reason}
end.
# Elixir: the same idiom in friendlier dress - still convention-driven.
def read_config(path) do
case File.read(path) do
{:ok, bin} -> {:ok, parse(bin)}
{:error, reason} -> {:error, reason}
end
end
Nothing in either language forces a caller to handle both branches, nor stops a typo'd atom ({:eror, reason}) from slipping through to fail far away. The dynamic bargain is: you trade compile-time guarantees for flexibility, fast iteration, trivial metaprogramming, and hot code loading - and you buy back reliability at runtime with supervisors that restart a crashed process into a known-good state. For a quarter-century of telecom-grade uptime, that bargain held. But teams kept wanting the compiler to catch shape errors earlier. Two very different answers emerged.
Erlang's answer: Dialyzer and success typings
Dialyzer - the DIscrepancy AnaLYZer for ERlang - is a static analysis tool that ships with OTP. You can point it at compiled BEAM files or source and it reports "discrepancies": definite type errors, unreachable clauses, redundant guards, and broken contracts. Crucially, it was designed to be adopted into existing multi-million-line codebases without anyone first annotating them, so it makes an unusual promise.
Dialyzer is built on success typings. Where a traditional type checker tries to prove a program cannot go wrong (and rejects anything it can't prove safe), a success typing works the other way: it infers, for each function, the most permissive type that describes every way the function could possibly succeed. Dialyzer then warns only when it can prove that two such typings can never overlap - that a call is guaranteed to fail at runtime if reached.
The consequence is a deliberate, signature design choice:
- No false positives. Every Dialyzer warning is a genuine discrepancy - "if this code runs, it crashes." Its warnings are sound in that sense.
- False negatives are expected. To keep that no-false-positive promise, Dialyzer stays silent about any mistake it cannot definitively prove. Many real bugs slip past it. As a type system, this makes it deliberately unsound - passing Dialyzer does not mean your program is type-correct, only that it contains no provable contradictions.
That asymmetry - "sound warnings, incomplete coverage" - is the whole point. It means a clean Dialyzer run is cheap to trust and never cries wolf, at the cost of being a safety net with real holes.
You sharpen Dialyzer by writing optional -spec contracts. These are documentation the tool actually checks:
-module(bank).
-export([withdraw/2]).
%% A type spec: developer-declared input and output types.
-spec withdraw(Balance :: non_neg_integer(),
Amount :: pos_integer()) ->
{ok, non_neg_integer()} | {error, insufficient_funds}.
withdraw(Balance, Amount) when Amount =< Balance ->
{ok, Balance - Amount};
withdraw(_Balance, _Amount) ->
{error, insufficient_funds}.
Dialyzer compares your -spec against the success typing it inferred from the code. If they conflict, you get a warning. Two named relationships matter:
- An underspec is a
-specstrictly more permissive than the inferred success typing (you promised less than the code guarantees). - An overspec is a
-specstrictly more restrictive than the success typing (you claimed the function does less than it actually can - so it might return something your spec forbids).
A spec that contradicts the code - say, claiming withdraw/2 can return {ok, atom()} - is flagged outright. Custom types compose, so specs can be quite expressive:
-type account() :: #{id := binary(), balance := non_neg_integer()}.
-type tx_error() :: insufficient_funds | account_frozen.
-spec transfer(account(), account(), pos_integer()) ->
{ok, account(), account()} | {error, tx_error()}.
Elixir exposes the very same machinery through @spec/@type and the dialyxir wrapper, because under the hood it is the same Dialyzer running over BEAM bytecode:
@type account :: %{id: binary(), balance: non_neg_integer()}
@spec withdraw(non_neg_integer(), pos_integer()) ::
{:ok, non_neg_integer()} | {:error, :insufficient_funds}
def withdraw(balance, amount) when amount <= balance,
do: {:ok, balance - amount}
def withdraw(_balance, _amount),
do: {:error, :insufficient_funds}
Dialyzer is genuinely valuable - it catches dead clauses, impossible matches, and contract violations across module boundaries - but it is opt-in, runs as a separate (often slow) pass, and by construction will never catch every shape error. That gap is exactly what Elixir's newer effort, and Gleam, set out to close from opposite directions.
Elixir's answer: gradual set-theoretic types in the compiler
Rather than bolt analysis on the side, Elixir is growing a type system inside the compiler, so type checking happens on every mix compile with no extra tool. The theory was published in an award-winning 2023 paper (Castagna, Duboc, and Valim), and the implementation has shipped incrementally ever since.
It is a set-theoretic system: types are built and combined with the basic set operations - unions, intersections, and negations. integer() or atom() is a union; dynamic() and integer() is an intersection. This algebra lets the checker describe Erlang/Elixir's wonderfully heterogeneous values (an atom or a tuple or a map of a specific shape) far more naturally than a classic ML system can.
It is also gradual, anchored by a special dynamic() type. dynamic() is not a hole that disables checking like any() in many gradual systems; it is checked optimistically and narrowed as a value is used. Code with no type information is treated as dynamic(), so existing programs keep compiling, while the checker still reports a violation the moment it can prove the supplied and expected types are disjoint - provably non-overlapping.
That disjointness rule is what gives the system its extremely low false-positive rate: it only flags things guaranteed to fail. The milestone-by-milestone rollout, in this environment's timeline:
- v1.17 (June 2024) - introduced set-theoretic data types and type checking of pattern matching.
- v1.18 (December 2024) - added type checking of function calls, plus inference of patterns and return types.
- v1.19 / v1.20 (through June 2026) - completed the first major milestone: Elixir now performs type inference of all language constructs and gradually type-checks every program, with no annotations required. The v1.20 announcement (3 June 2026) describes Elixir as, for the first time, "a gradually typed language."
The headline is that this is all inference-driven. You write ordinary Elixir; the compiler reconstructs types from your patterns and guards and reports two kinds of finding: dead code (clauses that can never match) and verified bugs (typing violations guaranteed to crash at runtime if reached).
def describe(value) when is_integer(value) do
# Inside this clause the compiler has narrowed `value` to integer().
# Calling a String function on it is a *provable* mismatch:
String.upcase(value)
# ^^^^^ warning: expected a binary, got integer()
end
def classify(:ok), do: "fine"
def classify(:error), do: "broken"
# A guard the compiler can prove is unreachable given the clauses above:
def classify(x) when is_integer(x) and is_binary(x) do
# warning: this clause can never match - integer() and binary()
# are disjoint, so the intersection is the empty type (none()).
"impossible"
end
Because it is inference-first, the system finds bugs in code that predates it without anyone editing a line. The roadmap (toward v1.21 and beyond) tackles the harder pieces needed before user-written type signatures arrive: recursive types, parametric types, and typed structs. For now Elixir occupies a genuinely distinct point on the spectrum - stronger than Dialyzer (built in, always on, no setup) and yet softer than Gleam (gradual, annotation-free, and forgiving wherever a type is genuinely unknown).
Gleam's answer: sound static typing, no escape hatches
Gleam (stable v1.0 on 4 March 2024, by Louis Pilfold) takes the opposite stance from everything above: it is statically typed by default, sound, and fully inferred, descending from the Hindley-Milner lineage of ML, OCaml, and Elm. There is no dynamic(), no any, no unchecked cast. If Gleam cannot prove your program type-correct, it will not compile - the inverse of Dialyzer's "warn only when provably wrong."
The same fallible-result idiom that is a convention in Erlang/Elixir is an enforced type in Gleam:
import gleam/int
pub fn withdraw(balance: Int, amount: Int) -> Result(Int, String) {
case balance >= amount {
True -> Ok(balance - amount)
False -> Error("insufficient funds")
}
}
pub fn run() -> String {
// The compiler forces you to handle BOTH outcomes - you cannot
// forget the Error branch, and you cannot mistype the tag.
case withdraw(100, 30) {
Ok(remaining) -> "left: " <> int.to_string(remaining)
Error(reason) -> "failed: " <> reason
}
}
Two words define Gleam's bargain:
- Inferred - you rarely write types; the compiler reconstructs them from how values flow. Annotations are optional documentation, not a tax.
- Sound - the inference does not lie. If Gleam says a value is an
Int, it is anIntat runtime, every time. There is no construct that quietly defeats the checker.
import gleam/list
// No annotations - the compiler infers fn(List(Int)) -> List(Int).
pub fn double_all(numbers) {
list.map(numbers, fn(n) { n * 2 })
}
Gleam also makes case exhaustiveness mandatory, so absence and new variants can't be forgotten - add a constructor to a type and every unhandled case becomes a compile error pointing you at the work. And it removes whole bug classes by construction: no null (absence is the explicit Option type), and no exceptions for expected failure (recoverable errors are Result values). Compare the contract from the top of this article, now with the absent case forced into the open:
import gleam/option.{type Option, None, Some}
pub fn first_even(numbers: List(Int)) -> Option(Int) {
case numbers {
[] -> None
[n, ..] if n % 2 == 0 -> Some(n)
[_, ..rest] -> first_even(rest)
}
}
// Every caller must acknowledge the None case - there is no
// "just dereference it and hope," because there is no null.
The price of soundness is rigidity: there is no dynamic() to lean on when you genuinely don't know a type, and crossing into untyped BEAM code requires a deliberate, typed FFI declaration with @external that you vouch for. That trade - give up flexibility, gain a guarantee - is precisely the one the dynamic languages declined, and it is why Gleam feels like a different philosophy rather than a different syntax.
The other two: LFE and Luerl stay dynamic
For completeness, the family's remaining members sit firmly at the dynamic end. LFE (Lisp Flavoured Erlang) uses Erlang's exact data types and runtime, so it is dynamically and strongly typed just like Erlang - and it can use Dialyzer and -spec-style contracts through the same OTP machinery, since it compiles to the same Core Erlang. Luerl runs Lua 5.x inside the BEAM and inherits Lua's dynamic typing wholesale; types live on values at runtime, with no static checking layer at all.
;; LFE: dynamic and strongly typed, Erlang terms as S-expressions.
(defun withdraw
((balance amount) (when (=< amount balance))
(tuple 'ok (- balance amount)))
((_balance _amount)
(tuple 'error 'insufficient-funds)))
-- Luerl runs this Lua on the BEAM: dynamic typing, runtime checks only.
local function withdraw(balance, amount)
if amount <= balance then
return balance - amount -- a number
else
return nil, "insufficient funds" -- nil + message, Lua's idiom
end
end
The spectrum at a glance
The BEAM family now spans the full range from "no static checking" to "sound static checking," with two genuinely novel midpoints:
| erlang | elixir | gleam | lfe | luerl | |
|---|---|---|---|---|---|
| Runtime typing | dynamic, strong | dynamic, strong | static, strong | dynamic, strong | dynamic, strong |
| Static checker | Dialyzer (opt-in) | built-in, always on | compiler (mandatory) | Dialyzer (opt-in) | none |
| Foundation | success typings | gradual set-theoretic | Hindley-Milner | success typings | n/a |
| Sound type system? | no (unsound by design) | gradual (sound where typed) | yes, fully sound | no | no |
| False positives | none (sound warnings) | extremely low | n/a (compile errors) | none | n/a |
| Annotations | optional -spec |
none required (yet) | optional (inferred) | optional -spec |
none |
| Null / absence | undefined, atoms |
nil |
none - Option |
atoms | nil |
A few honest caveats. "Sound" is doing double duty in that table: Dialyzer's warnings are sound (it never cries wolf), but as a type system it is unsound (it misses real errors) - the opposite axis from Gleam, whose type system is sound (it rejects all type-incorrect programs) at the cost of rejecting some correct-but-unprovable ones. Elixir's effort is the moving target: annotation-free inference today, user-written signatures and typed structs on the roadmap.
Choosing a point on the spectrum
There is no single "right" answer, which is rather the point of a multi-language VM:
- Want maximum flexibility, metaprogramming, and the classic "let it crash" workflow, with a sound safety net you can bolt on? Erlang or LFE with Dialyzer.
- Want that same dynamic feel but with a real type checker working for free on every compile, finding provable bugs in code you never annotated? Elixir, increasingly so with each release.
- Want the compiler to guarantee there are no shape errors, no nulls, and no forgotten failure cases before you ship - and you'll accept the rigidity that buys? Gleam.
- Embedding scripting or sandboxing untrusted logic? Luerl, dynamic by nature.
The remarkable thing is that all five interoperate on one runtime. You can wrap a battle-tested Erlang library behind a sound Gleam interface, call it from Elixir whose compiler quietly type-checks the surrounding code, and supervise the whole thing with the same OTP tree - choosing, function by function, exactly how much the compiler should prove for you.
Further reading
- Dialyzer documentation: https://www.erlang.org/doc/apps/dialyzer/dialyzer.html
- "Type Specifications and Erlang" (Learn You Some Erlang): https://learnyousomeerlang.com/dialyzer
- Elixir gradual set-theoretic types guide: https://hexdocs.pm/elixir/gradual-set-theoretic-types.html
- Elixir v1.20 release (gradually typed): https://elixir-lang.org/blog/2026/06/03/elixir-v1-20-0-released/
- Gleam language tour and v1.0 announcement: https://tour.gleam.run/ and https://gleam.run/news/gleam-version-1/