Introduction

Elixir is dynamically typed, and a lot of its appeal comes from that: no ceremony to define a function, fast feedback, expressive code.

But “typing in Elixir” is a bigger topic than it used to be. Today it covers three distinct things:

  • the runtime shape you get for free from pattern matching and structs,
  • optional typespecs, checked by Dialyzer,
  • and the set-theoretic type system now built into the compiler.

Let’s look at each.

Dynamically typed, not untyped

Elixir has types; it just doesn’t check them at compile time by default. You work with them constantly:

"hello"             # a binary (string)
:ok                 # an atom
123                 # an integer
%{key: "value"}     # a map
[1, 2, 3]           # a list
%User{name: "nico"} # a struct

Instead of a type checker, you lean on pattern matching, structs, and runtime errors when shapes don’t line up. For a lot of code, that’s enough.

Runtime shape: matching, guards, structs

Pattern matching enforces expectations without any type annotation. This function only accepts a %User{}, and raises on anything else:

def greet(%User{name: name}) do
  "Hello, #{name}!"
end

Guards add finer conditions:

def double(x) when is_integer(x), do: x * 2

And structs give data a fixed, self-documenting shape:

defmodule User do
  defstruct [:name, :email]
end

None of this is compile-time checking, but it’s real structure, enforced when the code runs.

Typespecs

When you want to document contracts (a public API, a complex return type), you annotate functions with @spec:

@spec fetch_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) do
  case Repo.get(User, id) do
    nil -> {:error, :not_found}
    user -> {:ok, user}
  end
end

You can name your own types, too:

@type status :: :active | :inactive | :banned

Typespecs are worth it on public functions and non-obvious return types, and they help editors and language servers. On short, obvious helpers they’re just noise.

Dialyzer

Typespecs are checked by Dialyzer. To be precise about it, Dialyzer is an Erlang/OTP tool, used from Elixir through the dialyxir library. It’s a success typing analyser: it doesn’t force you to write specs and doesn’t block compilation; it flags code it can prove is inconsistent. It’s useful, but it won’t catch everything, and it’s a separate step you run.

The built-in type system

The bigger news is that Elixir now has its own type system, built into the compiler. It started in Elixir 1.17 (2024) and has grown with each release since.

Right now it works by inference: the compiler infers types for your code and emits a warning when it’s certain a piece of code will fail, with no annotations required, and the checking crosses module and dependency boundaries. For example, passing a value that could be atom() or integer() into Integer.to_string/1 warns, because an atom isn’t accepted there.

There’s no user-facing type syntax yet. The roadmap is public and staged: inference first (where we are), then a way to define typed structs, then set-theoretic type signatures for functions, at which point the old Erlang-style typespecs are expected to be phased out. The system is designed to be sound and gradual, which is a different foundation from Dialyzer’s.

In practice that means you already get some compile-time type checking today, for free, just by upgrading Elixir, and it will only get stronger.

What to use, and when

  • Lean on pattern matching and structs as your first line of structure.
  • Add @spec to public functions and anything with a non-obvious return type; skip it on trivial helpers.
  • Run Dialyzer if you want deeper analysis of those specs, knowing it’s a help, not a guarantee.
  • Keep Elixir current to benefit from the compiler’s growing type checker without changing a line of your code.

Wrapping up

Typing in Elixir used to mean “typespecs and Dialyzer, if you bother.” That’s no longer the whole story: the language is quietly gaining a real, sound type system that meets you where dynamic code already is. You still choose how much to annotate, but you now get a baseline of compile-time checking for nothing, and the direction of travel is clearly toward more.

Have comments or want to discuss this topic?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht