Why This Matters Now
Elixir 1.20 is not just another compiler release; it marks a pivotal moment in the language’s history. For the first time, the compiler performs sound, flow-sensitive static analysis on idiomatic Elixir code. It doesn’t just check syntax; it builds a control-flow graph to prove properties about your data, surfacing errors that previously required runtime crashes or extensive test suites to detect.
This is not about turning Elixir into a statically typed language like Java or Go. It is about evolving the “Let it Crash” philosophy into “Prove it won’t crash”. The goal is to write idiomatic Elixir that is now provably safer, easier to refactor, and self-documenting.
If you maintain a large or long-lived codebase, the way you write function heads, data structures, and guards now directly impacts the compiler’s ability to protect you.
What “Gradual Typing” Means in Elixir
Elixir’s type system is built on three pillars:
Gradual: Types are optional. The system embraces the
dynamic()type, allowing valid runtime code to exist even if static types can’t be proven yet.Set-theoretic: Types are treated as sets of values.
integer()oratom()is a union set.integer()andatom()is an empty set (because no value can be both).Flow-sensitive: The compiler learns from your code’s control flow. A guard or a pattern match isn’t just a runtime check; it is a fact that refines the type of a variable for the rest of the block.
The Critical Distinction: Unlike Dialyzer, which uses “Success Typing” (optimistically assuming code is valid unless proven otherwise), the new system is sound. If it identifies a type violation (e.g., an empty intersection like String AND Integer), it guarantees that the code is buggy.
The Mental Model: Strong Arrows
The most important concept to internalize is the strong arrow.
In many dynamic languages, operators accept any type and return any type. In Elixir’s new system, a “strong arrow” is a function or operator that the compiler knows will crash if given input outside its domain.
Examples:
String.upcase/1is a strong arrow (crashes on non-strings).The
+operator is a strong arrow (crashes on non-numbers).
The Superpower: Backwards Inference
When the compiler sees x + 1, it infers backwards that x must be a number. You don’t need to annotate x; the usage dictates the type.
Takeaway: The more strong arrows you use (via guards, pattern matching, and kernel functions), the more the compiler learns about your code automatically.
Evolution of the Type System (v1.17 → v1.20)
Elixir 1.17: Detecting Logical Fallacies
This release introduced the set-theoretic core. Its biggest impact was detecting impossible comparisons derived from pattern matching.
Example: The compiler spots when a variable constrained by a match is compared against a disjoint type.
5 > "hello"It also emits warnings when you do structural comparison between structs. The most common cases are comparing Date and DateTime.
my_date < ~D[2010-04-17]Elixir 1.18: Function Boundaries
Inference extended across function calls, alongside gradual inference of patterns and return types.
The Check: The compiler can warn if you call a function with an incorrect pattern. Example: User.drive({:ok, %User{}}, car_choices) instead of User.driver(%User{}, car_choices). It can also warn you about clauses that will never match in case statements.
Impact: Function heads start acting as strict type filters. The compiler can detect dead code in case statements.
Elixir 1.19: Anonymous Functions & Protocols
Type checking expanded to:
Anonymous Functions: Although they default to a
dynamic()input,Example:fn x -> x + 1 endis now inferred as:dynamic() -> number(), thanks to strong arrows inside the body, the compiler infersxas a number inside the function and guarantees a numeric returnProtocols: Interpolating a struct that doesn’t implement
String.Charsinto a string ("#{user}") now warns at compile time.
Elixir 1.20: The “Completeness” Release
Currently in release candidate status, v1.20 closes the loop. It brings inference to the remaining “blind spots” of the language:
Universal Constructs:
receive,try/catch,withandforcomprehensions are now fully understood by the inference engine.General Maps: Previous versions focused on atom-keyed maps (struct-like). v1.20 adds support for maps with arbitrary keys and operations like
Map.putandMap.delete.
How to Write Elixir That the Compiler Understands
To maximize the benefits of this system, you don’t need to learn a new syntax, but you should adopt specific idiomatic patterns.
1. Require Strict Struct Updates
One of the hard deprecations introduced in v1.19 involves the struct update syntax.
The Old Way (Unsafe):
Elixir
def update(user, name) do
# Warning: user is 'dynamic', compiler can't verify keys
%User{user | name: name}
end
The New Idiomatic Way: You must provide "evidence" that the variable is a struct before updating it.
Elixir
def update(%User{} = user, name) do
# Compiler now KNOWS user is %User{}
%{user | name: name}
end
Note: Using the map update syntax %{...} after a match is now preferred as it removes redundancy while retaining type safety.
2. Pattern Match Structs on Function Clauses
Adding struct type pattern matching in function clauses will help the compiler detect calls with incorrect parameters and help you avoid typo errors when accessing struct fields.
defmodule User do
def full_name(%User{} = user) do
# compiler will emit warnings if you access
# an non-existing property, Example: User.neim
"#{user.name} #{user.surname}"
end
end
# This call will emit a warning
User.fullname({:ok, %User{})
3. Use Guards to “Narrow” Types
Treat guards as type assertions. A variable starts as term() (the set of all values). Every guard intersects that set.
Elixir
def normalize(s) when is_binary(s) do
# Inside this block, 's' is strictly binary().
# Calling String.trim(s) is provably safe.
String.trim(s)
end
Tip: v1.20+ can infer types from complex boolean guards like is_integer(x) or is_float(x).
4. Prefer Structs Over Maps
Generic maps are the “least informative” structure. They do not restrict the keys or the types of values they can contain, and therefore you need to do defensive programming to ensure they have the expected keys.
Map: Keys are unconstrained. Shape is implicit.
Struct: Keys are defined. Shape is explicit. Whenever possible, use a struct. It transforms a data container into a type carrier that propagates safety guarantees throughout your system.
Should Teams Still Use Dialyzer?
Yes, but its role has shifted.
Dialyzer uses Success Typing and excels at cross-module and library boundary checks, enforcing @spec contracts and catching inconsistencies the compiler doesn’t see.
The compiler’s new type system is sound and flow-sensitive, catching local logic errors, unreachable code, and invalid assumptions in real time.
Together, they complement each other: the compiler ensures local correctness, while Dialyzer validates global contracts. Teams should continue writing @specs to retain these benefits and ease future transitions to compiler-enforced signatures.
The Road Ahead (Next ~15 Months)
The “Inference Era” concludes with v1.20. The next phase involves explicit type features:
Inference across clauses and dependencies (RC3 - May 2026): Function calls into external libraries (like Phoenix or Ecto) will be type-checked.
Typed Structs (v1.21+): A way to define the types of struct fields natively (e.g.,
defstruct name: string()).Type Signatures: Eventually, we will get a syntax to express function contracts that the compiler enforces, replacing
@specfor pure static analysis.
Closing Thoughts
Elixir is not becoming a rigid, statically typed language. It is becoming a language where idiomatic code is safe code.
By writing clear Elixir: matching on structs, using guards, and relying on standard library functions, you enable the compiler to act as a second pair of eyes. It validates your assumptions as you type, catching logical errors early and reducing the class of bugs that would otherwise surface in production.
This shift fundamentally changes how we trust and evolve Elixir systems. Large codebases become easier to refactor, APIs become more self-documenting, and correctness improves without sacrificing the language’s flexibility or ergonomics. Elixir is not changing its philosophy, it is finally giving the compiler enough information to enforce it.


