Hey everyone, Alex here from Coding with Alex on sysseder.com! If you’ve been hanging around the functional programming watering holes lately, you’ve probably heard some massive news reverberating through the ecosystem. The Elixir team, led by José Valim and Guillaume Duboc, has been quietly pulling off one of the most ambitious language design feats of the decade: introducing a type system to Elixir.
For years, Elixir developers have loved the language for its concurrency model (thanks to the Erlang VM, BEAM), its ruby-like friendly syntax, and its powerful macro system. But as codebases grow, we all face that inevitable moment where we wish we had the compiler holding our hand, catching mismatched types before they hit production. The community has experimented with tools like Dialyzer, but let’s be honest—interpreting Dialyzer's opaque error messages sometimes feels like deciphering ancient runes.
Now, the paradigm is shifting. The Elixir core team is rolling out a brand-new, mathematical-first gradual type system. In this post, we’re going to dive deep into what gradual typing means for Elixir, look at how the compiler handles type checking under the hood, and walk through practical code examples so you can start leveraging these guardrails in your own projects today.
Understanding Gradual Typing: Why Not "Just Go TypeScript"?
When web developers think of typing added to a dynamic language, the mind immediately jumps to TypeScript. But Elixir is taking a fundamentally different approach called Set-theoretic Types.
In a dynamic language like Elixir, values have types, but variables don't. A variable can hold an integer, then a string, then a struct. If we were to suddenly force a strict, static type system onto Elixir, we would break backward compatibility and ruin the highly dynamic features that make the Phoenix framework and LiveView so incredibly productive.
Instead, Elixir's gradual type system is designed around three core principles:
- Gradual: The compiler will not force you to annotate every single function. You can opt-in to typing where it makes sense, and leave highly dynamic code untyped.
- Sound: If the type system says a piece of code is type-safe, it actually is. The compiler will not lie to you or let type mismatches slip through silent type-casts.
- Ergonomic: Types are inferred automatically where possible, reducing boilerplate. You don't need to write Java-style verbose declarations to get safety.
What are Set-Theoretic Types?
At the heart of Elixir's type system is the concept of sets. In this mental model, a type is simply a set of values. For example:
- The type
integer()is the set of all integers. - The type
atom()is the set of all atoms. - A union type like
integer() | sring()is the union of those two sets.
Because it's based on set theory, the type system can naturally handle intersections, negations, and complex pattern matching without breaking down. This maps perfectly to how Elixir developers already write code using pattern matching and guard clauses.
The Evolution: From v1.17 to the Latest Milestones
This isn't an overnight change; the Elixir team is taking a highly structured, multi-release approach to ensure stability.
In Elixir v1.17, the foundations were officially integrated. The compiler started tracking and typing basic data types (like atoms, integers, floats, and lists) and verifying pattern matching on these primitives. If you tried to pass an atom to a function expecting an integer in your pattern match, the compiler would warn you.
In Elixir v1.18 and the latest development branches leading into v1.19/v1.20, this capability has expanded drastically to include map typing, struct typing, and the integration of the brand new @type check directives that bridge user-defined types with the compiler's inference engine.
Let's look at how the compiler acts as a static analysis tool today without breaking your existing runtime.
Hands-On: How the Elixir Type Checker Works
Let's write some code to see this in action. We'll start with a simple module that processes user data. Under the new type system, the compiler uses local type inference to determine what is happening inside your functions.
defmodule BillingSystem do
# The compiler infers that this function takes a number and returns a number
def calculate_tax(amount) when is_number(amount) do
amount * 0.15
end
def process_payment(amount) do
# Here, we try to pass a string instead of a number
calculate_tax("one hundred dollars")
end
end
Even without any explicit type signatures (like @spec), the compiler can analyze this code during compilation. It looks at the guard clause when is_number(amount) and reasons: "Ah, calculate_tax/1 only accepts values belonging to the number() set."
Then, it looks at process_payment/1, sees that we are passing "one hundred dollars" (which belongs to the string() set, disjoint from number()), and flags a compilation warning:
warning: incompatible types found in function call:
calculate_tax("one hundred dollars")
Expected type: number()
Given type: String.t()
This is incredibly powerful because it requires zero configuration or extra code from the developer. It works right out of the box using your existing guard clauses.
Typing Maps and Structs
In real-world Elixir applications, we rarely pass around raw integers or strings. We pass around maps and structs. The latest iterations of Elixir's type system address this by allowing precise map and struct typing.
Consider a user struct representing a user in our system:
defmodule User do
defstruct [:name, :age, :role]
end
defmodule AdminController do
# We only want to promote users who are already moderators
def promote_to_admin(%User{role: :moderator} = user) do
%{user | role: :admin}
end
def run do
guest = %User{name: "Alice", age: 30, role: :guest}
# This will trigger a compiler warning!
promote_to_admin(guest)
end
end
Because the compiler now tracks the structure of fields inside structs, it knows that the guest variable has its role field set to :guest. When we pass it to promote_to_admin/1, which expects a User where role is strictly :moderator, the compiler raises a warning during compilation.
This eliminates a massive class of bugs where developers assume a struct has been updated or passed through a specific pipeline, only to have it crash at runtime in production.
The Roadmap: What's Next?
The Elixir team is rolling this out incrementally to ensure that the compiler's performance doesn't degrade. Running mix compile needs to remain blazing fast. Currently, the type checker runs in a highly optimized parallel phase alongside code generation.
In the upcoming releases, we can expect:
- Full Support for Behaviours and Protocols: Letting you type dynamic dispatches.
- Gradual Spec validation: The compiler will validate your
@spectags against the actual code, turning them from documentation into compile-time assertions. - Interactive Editor Integration: Language Server Protocol (LSP) updates that show type mismatches directly inside VS Code, Zed, or Neovim as you type.
Why This Matters to You
If you're managing production Elixir code, this is a game-changer. It means you get the safety of a statically typed language like Go or Rust, without losing the rapid prototyping capabilities, hot-code reloading, and actor-model concurrency that made you choose Elixir in the first place.
You don't need to rewrite your apps. You don't need to add thousands of lines of types. You just need to upgrade your Elixir version, and the compiler will immediately start catching bugs you didn't even know you had.
What Do You Think?
Are you excited about gradual typing in Elixir, or do you prefer the wild west of fully dynamic code? Have you tried out the latest versions and seen these warnings in action yet?
Let me know in the comments below! And if you want more deep dives into functional programming, cloud infrastructure, and modern software engineering, don't forget to subscribe to the newsletter.
Until next time, happy coding!