Elixir by Example

Typespecs and Types

Edit any cell and run it in your browser Download source

Elixir has two kinds of types. Typespecs are annotations you write: documentation for readers and input for tools like Dialyzer. The type checker built into the compiler (Elixir 1.17 and later) needs no annotations at all: it infers types from your code and warns about calls that can never work.

Start with typespecs. @type names a type, @spec describes a function. By convention a struct's module defines t(), the type of the struct itself.

defmodule Order do
  @enforce_keys [:id]
  defstruct [:id, status: :pending, items: []]

  # A union of literal atoms, written with `|`.
  @type status :: :pending | :paid | :shipped

  # A map type: `required(...)` keys must be present,
  # `optional(...)` keys may be. `String.t()` is a
  # UTF-8 string; `pos_integer()` is 1, 2, 3...
  @type item :: %{
          required(:sku) => String.t(),
          required(:qty) => pos_integer(),
          optional(:gift) => boolean()
        }

  # `%__MODULE__{...}` is this struct, with a type for
  # each field. `[item()]` is shorthand for
  # `list(item())`.
  @type t :: %__MODULE__{
          id: pos_integer(),
          status: status(),
          items: [item()]
        }

  # `@typep` is private: usable only inside this
  # module, and hidden from the docs.
  @typep count :: non_neg_integer()

  # `keyword()` is a keyword list, like the options
  # here.
  @spec new(pos_integer(), [item()], keyword()) :: t()
  def new(id, items, opts \\ []) do
    struct!(__MODULE__, [id: id, items: items] ++ opts)
  end

  @spec quantity(t()) :: count()
  def quantity(order), do: order.items |> Enum.map(& &1.qty) |> Enum.sum()

  @spec half(integer()) :: integer()
  def half(n), do: n / 2
end

order = Order.new(1, [%{sku: "TEA", qty: 2}, %{sku: "MUG", qty: 1, gift: true}])
IO.inspect(order)
IO.inspect(Order.quantity(order), label: "quantity")
%Order{
  id: 1,
  status: :pending,
  items: [%{sku: "TEA", qty: 2}, %{sku: "MUG", qty: 1, gift: true}]
}
quantity: 3

Specs are not enforced. The compiler stores them in the module for docs and tools, and that's all. half/1 promises an integer and returns a float.

IO.inspect(Order.half(3), label: "half(3)")
half(3): 1.5

Dialyzer is the classic tool that reads specs and hunts for contradictions. It's an optional extra, usually added to a project through the dialyxir package. The type checker below is different: it is part of the compiler and runs on every build.

The checker grew release by release: 1.17 inferred types from patterns, 1.18 checked function calls, 1.19 added anonymous functions and protocols, and 1.20 infers types for whole functions, guards included.

Type errors are compiler warnings. To show them on this page, Checker compiles a snippet with Code.compile_string/1 and captures the diagnostics with Code.with_diagnostics/1 instead of letting them print. It keeps each message up to the "where" section, which lists source locations. (~r/.../ is a regex; the Sigils page covers both it and ~S.)

defmodule Checker do
  def check(title, source) do
    {_modules, diagnostics} = Code.with_diagnostics(fn -> Code.compile_string(source) end)
    IO.puts("== #{title}")

    if diagnostics == [] do
      IO.puts("    (no warnings)")
    end

    for %{message: message} <- diagnostics do
      [kept | _] = String.split(message, "\nwhere ")
      kept |> String.trim() |> String.replace(~r/^(?=.)/m, "    ") |> IO.puts()
    end
  end
end

The classic bug: a function returns {:ok, order} and the caller forgets to unwrap it. The compiler infers from the pattern that ship/1 needs a map with status: :paid, and a tuple never is one. Without colors, the mismatched part is marked with -...-. Each snippet is a ~S""" string: a multi-line string that leaves #{...} alone, so the code inside isn't interpolated.

Checker.check("forgot to unwrap {:ok, _}", ~S"""
defmodule Shipping do
  def ship(%{status: :paid} = order), do: {:shipped, order}

  def run, do: ship({:ok, %{status: :paid}})
end
""")
== forgot to unwrap {:ok, _}
    incompatible types given to ship/1:

        ship({:ok, %{status: :paid}})

    given types:

        -{:ok, %{status: :paid}}-

    but expected one of:

        %{..., status: :paid}

A misspelled field. %Order{} in the pattern tells the checker the exact keys, so order.stauts can never work.

Checker.check("typo in a struct field", ~S"""
defmodule Report do
  def line(%Order{} = order), do: "order #{order.id}: #{order.stauts}"
end
""")
== typo in a struct field
    unknown key .stauts in expression:

        order.stauts

    the given type does not have the given key:

        dynamic(%Order{id: term(), status: term(), items: term()})

Since 1.20 the checker infers types from guards and from what the body does with a value. total/1 only multiplies, so it needs numbers, and a price straight from a form is a string.

Checker.check("a string where a number goes", ~S"""
defmodule Pricing do
  def total(line), do: line.price * line.qty

  def run, do: total(%{price: "9.99", qty: 2})
end
""")
== a string where a number goes
    incompatible types given to total/1:

        total(%{price: "9.99", qty: 2})

    given types:

        -%{price: binary(), qty: integer()}-

    but expected one of:

        %{..., price: float() or integer(), qty: float() or integer()}

Return types are inferred too. parse_qty/1 returns {:ok, n} or :error, never nil, so the nil clause in its caller is dead code.

Checker.check("a case clause that can never match", ~S"""
defmodule Form do
  defp parse_qty("one"), do: {:ok, 1}
  defp parse_qty(_), do: :error

  def qty(input) do
    case parse_qty(input) do
      {:ok, n} -> n
      :error -> 0
      nil -> 1
    end
  end
end
""")
== a case clause that can never match
    the following clause will never match:

        nil ->

    because it attempts to match on the result of:

        parse_qty(input)

    which has type:

        dynamic(:error or {:ok, integer()})

Also new in 1.20: a clause that earlier clauses already cover is reported as redundant.

Checker.check("a redundant clause", ~S"""
defmodule Labels do
  def label(s) when is_binary(s), do: s
  def label(a) when is_atom(a), do: Atom.to_string(a)
  def label(s) when is_binary(s), do: String.upcase(s)
end
""")
== a redundant clause
    the following clause is redundant:

        def label(s) when is_binary(s)

    it has type:

        binary()

    previous clauses have already matched on the following types:

        binary()
        atom()

The checker reads code, not specs. Here the spec says integer, the guard says string, and only the guard counts. The wrong spec itself goes unreported.

Checker.check("the guard wins over the spec", ~S"""
defmodule Shout do
  @spec shout(integer()) :: String.t()
  def shout(s) when is_binary(s), do: String.upcase(s)

  def run, do: shout(42)
end
""")
== the guard wins over the spec
    incompatible types given to shout/1:

        shout(42)

    given types:

        -integer()-

    but expected one of:

        binary()

It is gradual: a value it knows nothing about has type dynamic(), and it only warns when a call fails for every type it could be. Input from outside (JSON, user input, a message) stays unchecked until runtime, where the bad call still fails.

Checker.check("unknown input is not checked", ~S"""
defmodule Inbox do
  def ship(%{status: :paid} = order), do: {:shipped, order}

  def handle(input), do: ship(input)
end
""")

try do
  Inbox.handle(:oops)
rescue
  e in FunctionClauseError -> IO.puts("FunctionClauseError: " <> Exception.message(e))
end
== unknown input is not checked
    (no warnings)
FunctionClauseError: no function clause matching in Inbox.ship/1

So: write @spec and @type for public functions and structs, as documentation that tools can read. Let the compiler's inference catch bugs everywhere else; it needs nothing from you. Add Dialyzer if you want a second opinion.

Pitfall: a spec that lies is never caught by the compiler. half/1 above returns a float under an integer() spec, and the checker ignores specs entirely in Elixir 1.20 (its docs list function signatures as a future milestone). Dialyzer is the tool that compares specs with code.

Pitfall: any() and term() are the same typespec type: any value at all. Don't confuse them with dynamic() in the checker's messages (dynamic(%Order{...}) above). It marks a type the checker only partly knows, and it stays quiet about calls such a value might satisfy.

Pitfall: no warning doesn't mean correct. The checker reports only calls that fail for every possible type, so Inbox.handle/1 compiled silently and still crashed at runtime. Pattern match on input at the edges of your system, where it comes in.