Prerequisites
Typespecs and Dialyzer
Add type annotations to your Elixir code with @spec and @type, and catch bugs at compile time using Dialyzer and Dialyxir for static analysis.
Elixir is a dynamically typed language, but it has a rich type annotation system built in. Typespecs let you document the expected types of function arguments and return values. Dialyzer is a static analysis tool that reads these annotations (along with inferred types) and detects type inconsistencies, unreachable code, and other defects – all without running your program.
Typespecs serve double duty: they improve documentation and enable automated bug detection.
Defining Type Specifications
The @spec Attribute
The @spec attribute declares the types of a function’s parameters and return value.
defmodule Math do
@spec add(number(), number()) :: number()
def add(a, b), do: a + b
@spec divide(number(), number()) :: {:ok, float()} | {:error, String.t()}
def divide(_a, 0), do: {:error, "division by zero"}
def divide(a, b), do: {:ok, a / b}
@spec factorial(non_neg_integer()) :: pos_integer()
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)
end
Typespecs Are Not Enforced at Runtime
@spec annotations at runtime. A function annotated as @spec greet(String.t()) :: String.t() will happily accept an integer if called with one. The annotations exist for documentation and static analysis tools like Dialyzer. Think of them as machine-readable documentation that tooling can verify.Built-in Types
Elixir provides a wide set of built-in types for use in specs.
@spec example1() :: integer() # ..., -1, 0, 1, ...
@spec example2() :: float() # IEEE 754 floats
@spec example3() :: number() # integer() | float()
@spec example4() :: boolean() # true | false
@spec example5() :: atom() # :ok, :error, etc.
@spec example6() :: String.t() # UTF-8 binary string
@spec example7() :: binary() # any binary
@spec example8() :: pid() # process identifier
@spec example9() :: reference() # make_ref() values
@spec example10() :: any() # any type at all
@spec example11() :: term() # alias for any()
@spec example12() :: no_return() # function never returns
@spec example13() :: list(integer()) # [1, 2, 3]
@spec example14() :: [integer()] # same as above
@spec example15() :: {atom(), String.t()} # {:ok, "hi"}
@spec example16() :: map() # any map
@spec example17() :: %{name: String.t()} # map with key
@spec example18() :: keyword(integer()) # [a: 1, b: 2]
@spec example19() :: nil # nil literal
@spec example20() :: :ok | :error # union of atoms
@spec example21() :: 1..100 # integer range
@spec example22() :: (integer() -> boolean()) # function type
Defining Custom Types
Use @type, @typep, and @opaque to define reusable type names within a module.
defmodule User do
@type t :: %__MODULE__{
name: String.t(),
email: String.t(),
age: non_neg_integer(),
role: role()
}
@type role :: :admin | :editor | :viewer
# Private type -- only visible within this module
@typep internal_id :: pos_integer()
# Opaque type -- visible to other modules by name,
# but they should not inspect its internal structure
@opaque token :: {internal_id(), String.t()}
defstruct [:name, :email, :age, :role]
@spec new(String.t(), String.t(), non_neg_integer(), role()) :: t()
def new(name, email, age, role \\ :viewer) do
%__MODULE__{name: name, email: email, age: age, role: role}
end
@spec admin?(t()) :: boolean()
def admin?(%__MODULE__{role: :admin}), do: true
def admin?(_user), do: false
end
iex> user = User.new("Alice", "[email protected]", 30, :admin)
%User{name: "Alice", email: "[email protected]", age: 30, role: :admin}
iex> User.admin?(user)
true
iex> User.admin?(User.new("Bob", "[email protected]", 25))
false
@type t :: %__MODULE__{...} is a strong Elixir convention for structs. It lets other modules write User.t() in their own specs, making the codebase self-documenting.Union Types and Complex Specs
Specs can express sophisticated type relationships using the | (union) operator.
defmodule Config do
@type value :: String.t() | integer() | boolean() | nil
@type config :: %{optional(atom()) => value()}
@spec get(config(), atom()) :: value()
def get(config, key), do: Map.get(config, key)
@spec get(config(), atom(), value()) :: value()
def get(config, key, default), do: Map.get(config, key, default)
@spec fetch!(config(), atom()) :: value()
def fetch!(config, key) do
case Map.fetch(config, key) do
{:ok, val} -> val
:error -> raise KeyError, key: key, term: config
end
end
end
Running Dialyzer
Dialyzer (DIscrepancy AnaLYZer for ERlang programs) performs static analysis on compiled BEAM bytecode. The easiest way to use it in an Elixir project is through the Dialyxir library.
Setting Up Dialyxir
# In mix.exs, add to deps:
defp deps do
[
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
]
end
Then run:
# First run builds the PLT (Persistent Lookup Table)
# This takes several minutes but only happens once
mix dialyzer
# Subsequent runs are much faster
mix dialyzer
mix dialyzer, it builds a PLT (Persistent Lookup Table) containing type information for Erlang/OTP, Elixir standard libraries, and your dependencies. This can take 5-15 minutes. Subsequent runs only analyze your changed code and are much faster.Common Dialyzer Warnings
Dialyzer can detect several categories of problems.
defmodule Problematic do
# Warning: function has no local return
# (the function can never successfully return)
@spec always_fails() :: :ok
def always_fails do
raise "boom"
:ok # unreachable code
end
# Warning: the pattern can never match
@spec check(integer()) :: String.t()
def check(x) when is_integer(x) do
case x do
_ when is_binary(x) -> x # impossible -- x is an integer
_ -> "number"
end
end
# Warning: contract violation
# The spec says String.t() but function returns an atom
@spec name() :: String.t()
def name, do: :alice
end
iex> # Dialyzer output examples (from mix dialyzer):
iex> # lib/problematic.ex:5: Function always_fails/0 has no local return
iex> # lib/problematic.ex:12: The pattern can never match the type integer()
iex> # lib/problematic.ex:19: Invalid type specification for function name/0
Dialyzer Configuration
You can configure Dialyzer in your mix.exs project definition:
def project do
[
app: :my_app,
# ... other config
dialyzer: [
plt_add_apps: [:mnesia], # include extra apps in PLT
flags: [
:unmatched_returns, # warn on ignored return values
:error_handling, # warn on error handling issues
:underspecs, # warn when spec is too broad
:no_opaque # warn on opaque type violations
],
ignore_warnings: ".dialyzer_ignore.exs"
]
]
end
Dialyzer's Success Typing
Typespecs in Practice
Documenting Callbacks with Specs
Typespecs work hand-in-hand with behaviours. The @callback attribute is essentially a @spec for behaviour functions:
defmodule Cache do
@type key :: String.t()
@type value :: term()
@type ttl :: pos_integer() | :infinity
@callback get(key()) :: {:ok, value()} | :miss
@callback put(key(), value(), ttl()) :: :ok
@callback delete(key()) :: :ok
@optional_callbacks [delete: 1]
end
defmodule ETSCache do
@behaviour Cache
@impl Cache
@spec get(Cache.key()) :: {:ok, Cache.value()} | :miss
def get(key) do
case :ets.lookup(:cache, key) do
[{^key, val}] -> {:ok, val}
[] -> :miss
end
end
@impl Cache
@spec put(Cache.key(), Cache.value(), Cache.ttl()) :: :ok
def put(key, value, _ttl) do
:ets.insert(:cache, {key, value})
:ok
end
end
Add Typespecs to a Module
Take the following module and add complete type specifications:
- Define a
@type tfor theAccountstruct. - Define a custom
@type currencyas a union of:usd | :eur | :gbp. - Add
@specto every function. - Run
mix dialyzer(if you have a project set up) and fix any issues.
defmodule Account do
defstruct [:holder, :balance, :currency]
def new(holder, currency \\ :usd) do
%__MODULE__{holder: holder, balance: 0, currency: currency}
end
def deposit(%__MODULE__{balance: bal} = acct, amount) when amount > 0 do
{:ok, %{acct | balance: bal + amount}}
end
def deposit(_acct, _amount), do: {:error, "amount must be positive"}
def withdraw(%__MODULE__{balance: bal} = acct, amount)
when amount > 0 and amount <= bal do
{:ok, %{acct | balance: bal - amount}}
end
def withdraw(_acct, _amount), do: {:error, "insufficient funds or invalid amount"}
def balance(%__MODULE__{balance: bal, currency: cur}) do
{cur, bal}
end
end
Modern Type-System Direction in Elixir
Typespecs plus Dialyzer remain the primary production path for static analysis in Elixir today. At the same time, the community has active work and discussion around richer typing models (including set-theoretic approaches) to improve expressiveness and tooling feedback.
What this means in practice right now:
- Keep using
@spec,@type, and@callbackfor API contracts. - Run Dialyzer in CI for regression detection.
- Prefer precise, composable type definitions over broad
term()usage in public APIs.
Why This Matters for Your Codebase
If your type contracts are clear and consistent today, you can adopt improved tooling later with less churn. Poorly defined specs create migration friction regardless of which future checker you choose.
Practical habits that age well:
- define module-level
t()types for structs, - use union types to model domain states explicitly,
- avoid overly broad specs when domain constraints are known,
- keep behavior callback specs precise and documented.
Example: Tightening a Public API Spec
defmodule Payments do
@type currency :: :usd | :eur | :gbp
@type amount :: pos_integer()
@type transfer_error :: :insufficient_funds | :account_locked | :invalid_currency
@spec transfer(String.t(), String.t(), amount(), currency()) ::
:ok | {:error, transfer_error()}
def transfer(from_id, to_id, amount, currency) do
# implementation omitted
end
end
This style gives better guidance to humans today and is easier to analyze by current and future type tooling.
Summary
Typespecs bring structured type documentation to Elixir without sacrificing its dynamic nature. They serve as machine-readable documentation that helps both developers and tools understand your code. Dialyzer reads these annotations and uses success typing to find real bugs with zero false positives. The investment of adding @spec, @type, and @callback annotations pays off in clearer APIs, better documentation, and automated defect detection – especially as your codebase grows.
FAQ and Troubleshooting
Why is my Typespecs and Dialyzer example failing even though the code looks right?
Most failures come from runtime context, not syntax: incorrect app configuration, missing dependencies, process lifecycle timing, or environment-specific settings. Re-run with smaller examples, inspect intermediate values, and verify each prerequisite from this lesson before combining patterns.
How do I debug this topic in a production-like setup?
Start with reproducible local steps, add structured logs around boundaries, and isolate one moving part at a time. Prefer deterministic tests for the core logic, then layer integration checks for behavior that depends on supervisors, networked services, or external systems.
What should I optimize first?
Prioritize correctness and observability before performance tuning. Once behavior is stable, profile the hot paths, remove unnecessary work, and only then introduce advanced optimizations.
Related Lessons
Key Takeaways
- Typespecs and Dialyzer are the practical baseline for typed reasoning in Elixir today
- Dialyzer's success typing catches proven defects but does not provide full compile-time guarantees
- You can design APIs that work well now and stay compatible with richer future type tooling