Elixir Examples
Advanced 25 min read Phase 5

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

Unlike languages with static type systems, Elixir does not check @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
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
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>Tip
Using @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
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>Warning
The first time you run 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
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

Dialyzer uses a technique called success typing rather than traditional type inference. It only reports warnings when it can prove something is wrong. This means Dialyzer has no false positives – if it reports a warning, there really is an issue. The tradeoff is that it can miss some bugs that a stricter type system would catch. Think of it as a “guaranteed problems” detector rather than a “complete correctness” verifier.

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:

  1. Define a @type t for the Account struct.
  2. Define a custom @type currency as a union of :usd | :eur | :gbp.
  3. Add @spec to every function.
  4. 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:

  1. Keep using @spec, @type, and @callback for API contracts.
  2. Run Dialyzer in CI for regression detection.
  3. 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.

<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>Note
Treat modern Elixir typing as an evolving stack: build with typespec discipline now, keep Dialyzer in your feedback loop, and follow Elixir release notes for new type-system capabilities as they mature.

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

Further Reading on HexDocs

Typespecs Dialyzer Dialyxir

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