Spec Coverage
Common @spec Patterns
Basic Types
@spec greet(String.t()) :: String.t()
def greet(name), do: "Hello, #{name}!"
@spec count_items(list()) :: non_neg_integer()
def count_items(items), do: length(items)
@spec enabled?() :: boolean()
def enabled?, do: Application.get_env(:my_app, :enabled, false)
Union Types
@spec fetch_account(integer()) :: {:ok, Account.t()} | {:error, :not_found | :suspended}
def fetch_account(id) do
case Repo.get(Account, id) do
nil -> {:error, :not_found}
%Account{status: :suspended} = account -> {:error, :suspended}
account -> {:ok, account}
end
end
Custom Types
@type id :: pos_integer()
@type reason :: :not_found | :unauthorized | :timeout
@type result :: {:ok, t()} | {:error, reason()}
@spec find(id()) :: result()
def find(id), do: # ...
Keyword Options
@type option :: {:timeout, pos_integer()} | {:retries, non_neg_integer()}
@spec request(String.t(), [option()]) :: {:ok, Response.t()} | {:error, term()}
def request(url, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5_000)
retries = Keyword.get(opts, :retries, 3)
# ...
end
When Clauses
@spec transform(list(a), (a -> b)) :: list(b) when a: term(), b: term()
def transform(items, func), do: Enum.map(items, func)
@spec wrap(value) :: [value] when value: term()
def wrap(value), do: [value]
@type and @typedoc
Use custom types to name domain concepts and reduce repetition.
defmodule MyApp.Shipping do
@typedoc "Weight in grams."
@type weight :: non_neg_integer()
@typedoc "A geographic coordinate pair."
@type coordinates :: {latitude :: float(), longitude :: float()}
@typedoc "Shipping status throughout the delivery lifecycle."
@type status :: :pending | :in_transit | :delivered | :returned
@spec estimate_cost(weight(), coordinates(), coordinates()) :: {:ok, Decimal.t()}
def estimate_cost(weight, origin, destination) do
# ...
end
end
Benefits:
weight()communicates intent better thannon_neg_integer()status()centralizes valid values -- add a new status in one place@typedocappears in ExDoc, making types self-documenting
When @spec Is Required
All public exported functions should have @spec. This includes:
defmodule MyApp.Accounts do
# Required: public function
@spec create_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def create_user(attrs), do: # ...
# Required: public function with default args
@spec list_users(keyword()) :: [User.t()]
def list_users(opts \\ []), do: # ...
# Required: public function used as callback
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts), do: # ...
end
When @spec Is Optional
defmodule MyApp.Accounts do
# Optional: private function
defp normalize_email(email), do: String.downcase(email)
# Optional: macro-generated functions (e.g., Ecto schema fields)
# These are generated by `schema` and `field` macros
# Optional: @impl true callbacks where the behaviour defines the spec
@impl true
def handle_call(:ping, _from, state), do: {:reply, :pong, state}
end
Common @spec Mistakes
Overly Broad term() or any()
# BAD - term() hides what the function actually accepts
@spec process(term()) :: term()
def process(%Order{} = order), do: # ...
# GOOD - spec reflects the actual types
@spec process(Order.t()) :: {:ok, Receipt.t()} | {:error, String.t()}
def process(%Order{} = order), do: # ...
Using term() or any() defeats the purpose of specs. If you know the type, declare it.
Missing Union Branches
# BAD - forgets the nil case from Repo.get
@spec find_user(integer()) :: {:ok, User.t()}
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found} # Not reflected in spec!
user -> {:ok, user}
end
end
# GOOD - all return paths represented
@spec find_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
Not Using Custom Types for Repeated Patterns
# BAD - same tuple pattern repeated across many functions
@spec create(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec update(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec delete(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
# GOOD - define a type once, reuse it
@type changeset_result :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec create(map()) :: changeset_result()
@spec update(User.t(), map()) :: changeset_result()
@spec delete(User.t()) :: changeset_result()
Specs That Don't Match Function Clauses
# BAD - spec says it only accepts String.t() but function also handles nil
@spec normalize(String.t()) :: String.t()
def normalize(nil), do: ""
def normalize(value), do: String.trim(value)
# GOOD - spec covers all clauses
@spec normalize(String.t() | nil) :: String.t()
def normalize(nil), do: ""
def normalize(value), do: String.trim(value)
Using list() When a Specific Element Type Is Known
# BAD - list() tells the caller nothing about contents
@spec active_users() :: list()
def active_users, do: Repo.all(from u in User, where: u.active == true)
# GOOD - caller knows what's in the list
@spec active_users() :: [User.t()]
def active_users, do: Repo.all(from u in User, where: u.active == true)
Dialyzer-Friendly Specs
Dialyzer uses @spec to perform success typing analysis. Specs that help Dialyzer catch real bugs:
# Dialyzer can catch callers passing wrong types
@spec send_notification(User.t(), String.t()) :: :ok | {:error, :delivery_failed}
def send_notification(%User{email: email}, message) do
# ...
end
# Dialyzer can verify pattern match exhaustiveness
@type role :: :admin | :editor | :viewer
@spec permissions(role()) :: [atom()]
def permissions(:admin), do: [:read, :write, :delete]
def permissions(:editor), do: [:read, :write]
def permissions(:viewer), do: [:read]
# Dialyzer warns if a new role is added to the type but not handled here
Tips for Dialyzer compatibility:
- Avoid
@spec function() :: no_return()unless the function truly never returns (e.g., raises always) - Use
String.t()instead ofbinary()for text data -- they're equivalent to Dialyzer butString.t()communicates intent - Declare
@opaquetypes when internal representation should not leak to callers
Review Questions
- Do all public functions have @spec?
- Do specs accurately reflect all return paths (including error tuples)?
- Are custom @type definitions used for repeated patterns?
- Are specs specific enough to catch real bugs (no unnecessary term() or any())?