- Add ## Contents and ## Decision Tree to all 10 existing pattern files - Fix embed_as/1 semantics inversion in types.md (:self → :dump) - Fix fabricated __meta__.changes reference in changesets.md - Fix default primary key type (:integer → :id) in schemas.md - Combine @impl subsections into single "Minimal Callback Annotation"
28 KiB
Typespecs Patterns
Patterns extracted from the Elixir standard library source code.
Contents
- Public Type with @typedoc
- Private Types with @typep
- @opaque Types (Protocol t())
- Union Types in @spec Return Values
whenConstraints in Specs- Map Types with required/optional Keys
- Keyword List Types for Options
- Parameterized Types (t/1)
- Named Parameters in Specs (:: annotation)
- @typedoc since: Annotation
1. Public Type with @typedoc
Source: lib/elixir/lib/gen_server.ex#L862
What it does: Every public @type is preceded by a @typedoc that explains what the type represents, often referencing which functions use it.
Why: Types are part of the public API. Without documentation, users must guess what a type means from its definition alone. The @typedoc bridges intent and implementation.
Anti-pattern: Defining @type without any @typedoc, leaving users to decipher complex union types.
Code example:
@typedoc "Return values of `start*` functions"
@type on_start :: {:ok, pid} | :ignore | {:error, {:already_started, pid} | term}
@typedoc "The GenServer name"
@type name :: nil | atom | {:global, term} | {:via, module, term}
@typedoc """
The server reference.
This is either a plain PID or a value representing a registered name.
...
"""
@type server :: pid | name | {atom, node}
@typedoc """
Tuple describing the client of a call request.
`pid` is the PID of the caller and `tag` is a unique term used to identify the
call.
"""
@type from :: {pid, tag :: term}
When to Use
Triggers:
- You define a public
@typethat appears in any@specor callback signature - The type has more than one clause or is a tagged tuple
- Other modules will reference this type
Example — before:
@type status :: :pending | :active | :suspended | :terminated
Example — after:
@typedoc """
Account lifecycle status.
Used by `activate/1`, `suspend/1`, and `terminate/1` to indicate
the current state of a user account.
"""
@type status :: :pending | :active | :suspended | :terminated
When NOT to Use
Don't use this when:
- The type is private (
@typep) and only used internally within the module - The type name is completely self-explanatory and has a single, obvious clause (e.g.,
@type id :: pos_integer())
Over-application example:
@typedoc "A boolean value"
@type enabled :: boolean()
@typedoc "The name"
@type name :: String.t()
Better alternative:
# Self-explanatory single-clause types don't need @typedoc
@type enabled :: boolean()
@type name :: String.t()
Why: Adding a @typedoc that merely restates the type name and definition adds noise without information. Reserve @typedoc for types where the name alone doesn't convey the full meaning.
2. Private Types with @typep
Source: lib/elixir/lib/macro.ex#L84, 97
What it does: Uses @typep for internal recursive type definitions that are implementation details not meant for external consumers.
Why: Keeps the public type surface small while allowing internal type reuse. Private types can reference themselves recursively (e.g., for AST node structure) without polluting the documentation.
Anti-pattern: Making all types public "just in case." This bloats documentation and creates implicit public API guarantees.
Code example:
@typedoc "The inputs of a macro"
@type input ::
input_expr
| {input, input}
| [input]
| atom
| number
| binary
@typep input_expr :: {input_expr | atom, metadata, atom | [input]}
@typedoc "The output of a macro"
@type output ::
output_expr
| {output, output}
| [output]
| atom
| number
| binary
| captured_remote_function
| pid
@typep output_expr :: {output_expr | atom, metadata, atom | [output]}
When to Use
Triggers:
- A type is used only within the defining module (helper type for recursion, intermediate structure)
- You want to DRY up repeated type expressions inside the module without exposing them
- The type represents an internal data structure that could change without notice
Example — before:
# Repeated inline in multiple specs
@spec parse(String.t()) :: {atom | {atom, list, list}, map(), atom | list}
@spec transform({atom | {atom, list, list}, map(), atom | list}) :: String.t()
Example — after:
@typep node :: {atom | {atom, list, node}, map(), atom | [node]}
@spec parse(String.t()) :: node()
@spec transform(node()) :: String.t()
When NOT to Use
Don't use this when:
- Other modules need to reference the type in their own specs
- The type is part of a public struct or protocol contract
- You want Dialyzer to catch misuse across module boundaries (opaque types are better for this)
Over-application example:
defmodule Parser do
@typep ast :: {atom, keyword(), [ast]}
# But then in another module...
end
defmodule Transformer do
# Can't reference Parser.ast() — it's private!
@spec transform(term()) :: term() # forced to use term()
def transform(ast), do: ...
end
Better alternative:
defmodule Parser do
@type ast :: {atom, keyword(), [ast]}
# OR use @opaque if you want to hide the structure but allow cross-module reference
end
defmodule Transformer do
@spec transform(Parser.ast()) :: Parser.ast()
def transform(ast), do: ...
end
Why: @typep hides the type from other modules entirely. If cross-module usage exists, you need @type (public) or @opaque (hidden structure, public name).
3. @opaque Types (Protocol t())
Source: lib/elixir/lib/protocol.ex#L150 (documentation), runtime generation
What it does: Protocols auto-generate an opaque @type t :: term() type that represents "any value implementing this protocol."
Why: The type is opaque because the concrete implementations are open-ended — any module can implement the protocol. Making it opaque prevents users from pattern-matching on the internal representation (which could be anything) and signals that you should only use the protocol functions.
Anti-pattern: Defining @opaque for types where consumers need to destructure the value. Use @opaque only when the internal representation is truly hidden.
Code example:
# Generated automatically by defprotocol — documented behavior:
defprotocol Size do
@doc "Calculates the size (and not the length!) of a data structure"
def size(data)
end
# Usage in specs:
@spec print_size(Size.t()) :: :ok
def print_size(data) do
# ...
end
When to Use
Triggers:
- You build a data structure whose internals should never be accessed directly (e.g., wrapper around ETS, NIF resource, or complex nested map)
- You provide a complete set of accessor/manipulation functions and want to enforce using them
- You define a protocol where the implementing type is unknowable at definition time
Example — before:
@type t :: %__MODULE__{items: list(), size: non_neg_integer()}
# Users can (and will) do: queue.items |> Enum.reverse()
Example — after:
@opaque t :: %__MODULE__{items: list(), size: non_neg_integer()}
# Dialyzer warns if users access .items directly
# They must use Queue.push/2, Queue.pop/1, Queue.size/1
When NOT to Use
Don't use this when:
- Users legitimately need to pattern-match or destructure the value (e.g., tagged tuples like
{:ok, value}) - The struct fields are part of the documented public API
- The type is simple enough that hiding it adds ceremony without protection
Over-application example:
defmodule Config do
@opaque t :: %__MODULE__{timeout: pos_integer(), retries: non_neg_integer()}
defstruct [:timeout, :retries]
# Now users can't do: config.timeout — Dialyzer complains
# You're forced to write getters for every field
def timeout(%__MODULE__{timeout: t}), do: t
def retries(%__MODULE__{retries: r}), do: r
end
Better alternative:
defmodule Config do
@type t :: %__MODULE__{timeout: pos_integer(), retries: non_neg_integer()}
defstruct [:timeout, :retries]
# Users access fields directly — it's just config, not a black box
end
Why: @opaque is a contract that says "you may not look inside." If users need direct field access and the struct layout is stable, a regular @type is the right choice.
4. Union Types in @spec Return Values
Source: lib/elixir/lib/gen_server.ex#L577, 647–658
What it does: Uses union types with descriptive tagged tuples in callback specs, making all possible return shapes explicit.
Why: OTP callbacks accept multiple return shapes (e.g., {:reply, ...}, {:noreply, ...}, {:stop, ...}). Spelling out every variant in the spec enables Dialyzer and makes the contract self-documenting.
Anti-pattern: Using term() as a catch-all return type when specific shapes are known. This defeats the purpose of typespecs.
Code example:
@callback init(init_arg :: term) ::
{:ok, state}
| {:ok, state, timeout | :hibernate | {:continue, continue_arg :: term}}
| :ignore
| {:stop, reason :: term}
when state: term
@callback handle_call(request :: term, from, state :: term) ::
{:reply, reply, new_state}
| {:reply, reply, new_state,
timeout | :hibernate | {:continue, continue_arg :: term}}
| {:noreply, new_state}
| {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg :: term}}
| {:stop, reason, reply, new_state}
| {:stop, reason, new_state}
when reply: term, new_state: term, reason: term
When to Use
Triggers:
- A function can return multiple distinct shapes (tagged tuples, atoms, or different structures)
- You're defining a behaviour callback where implementers need to know all valid returns
- The function has error cases that should be visible in the type signature
Example — before:
@spec fetch(key :: String.t()) :: term()
Example — after:
@spec fetch(key :: String.t()) ::
{:ok, value :: term()}
| {:error, :not_found}
| {:error, :expired, expired_at :: DateTime.t()}
When NOT to Use
Don't use this when:
- The function truly returns an unbounded set of types (e.g., a deserializer that can return any Elixir term)
- You're listing so many variants that the spec becomes harder to read than the function body itself
- The union has more than ~6-8 clauses — consider defining a named
@typeinstead
Over-application example:
@spec process(input()) ::
{:ok, result()}
| {:error, :invalid_input}
| {:error, :timeout}
| {:error, :network_error}
| {:error, :parse_error}
| {:error, :rate_limited}
| {:error, :unauthorized}
| {:error, :not_found}
| {:error, :server_error}
| {:error, :unknown}
Better alternative:
@type error_reason ::
:invalid_input | :timeout | :network_error | :parse_error
| :rate_limited | :unauthorized | :not_found | :server_error | :unknown
@spec process(input()) :: {:ok, result()} | {:error, error_reason()}
Why: Extract large unions into named types. The spec stays readable, the error taxonomy is reusable, and you can add @typedoc to explain each reason.
5. when Constraints in Specs
Source: lib/elixir/lib/kernel.ex#L635, 1072, 1455
What it does: Uses the when clause in @spec to bind type variables, expressing relationships between parameters and return values.
Why: When the same type variable appears in multiple positions, it communicates that those values are related (same type). This is critical for generic functions like hd/1, max/2.
Anti-pattern: Using unrelated type names in different argument positions when the same variable would express the contract more precisely.
Code example:
@spec hd(nonempty_maybe_improper_list(elem, term)) :: elem when elem: term
@spec max(first, second) :: first | second when first: term, second: term
@spec tl(nonempty_maybe_improper_list(elem, last)) :: maybe_improper_list(elem, last) | last
when elem: term, last: term
When to Use
Triggers:
- A function's return type depends on its input type (generic/polymorphic functions)
- The same value flows through unchanged and you want to express that
- Multiple parameters must share a type constraint
Example — before:
@spec get_or_default(map(), atom(), term()) :: term()
Example — after:
@spec get_or_default(map(), atom(), default) :: term() | default when default: term()
When NOT to Use
Don't use this when:
- The relationship between input and output types is not actually enforced at runtime
- You have a single type variable that only appears once (it adds syntax without expressing a relationship)
- The constraint is always
when x: termwith no actual narrowing — it's just noise
Over-application example:
@spec log(message) :: :ok when message: String.t()
Better alternative:
@spec log(String.t()) :: :ok
Why: A when clause with a single variable that appears only once in the spec is equivalent to inlining the type. The when syntax only adds value when the same variable appears in multiple positions, expressing a type relationship.
6. Map Types with required/optional Keys
Source: lib/elixir/lib/supervisor.ex#L602, 644–652
What it does: Uses map type syntax with required() and optional() keys to define struct-like specs where some fields have defaults.
Why: Supervisors have a child_spec map where :id and :start are mandatory but :restart, :shutdown etc. are optional with defaults. The type system reflects this precisely.
Anti-pattern: Using a plain map() type or making all keys required when some have sensible defaults.
Code example:
@type sup_flags() :: %{
strategy: strategy(),
intensity: non_neg_integer(),
period: pos_integer(),
auto_shutdown: auto_shutdown()
}
@type child_spec :: %{
required(:id) => atom() | term(),
required(:start) => {module(), function_name :: atom(), args :: [term()]},
optional(:restart) => restart(),
optional(:shutdown) => shutdown(),
optional(:type) => type(),
optional(:modules) => [module()] | :dynamic,
optional(:significant) => boolean()
}
When to Use
Triggers:
- You accept a map (not a struct) with a mix of mandatory and optional keys
- The function has a "config" or "opts" map parameter with defaults for some keys
- You're documenting a protocol/behaviour where implementers return option maps
Example — before:
@spec connect(map()) :: {:ok, pid()}
Example — after:
@type connect_opts :: %{
required(:host) => String.t(),
required(:port) => pos_integer(),
optional(:timeout) => pos_integer(),
optional(:ssl) => boolean(),
optional(:pool_size) => pos_integer()
}
@spec connect(connect_opts()) :: {:ok, pid()}
When NOT to Use
Don't use this when:
- You're defining a struct — structs already enforce their fields via
defstructand@enforce_keys - The map is truly open-ended (arbitrary keys) — use
%{optional(atom()) => term()} - A keyword list would be more idiomatic for the use case (most Elixir option APIs use keyword lists, not maps)
Over-application example:
# Defining required/optional for a struct
@type t :: %__MODULE__{
required(:name) => String.t(),
optional(:email) => String.t()
}
Better alternative:
# Structs use the standard struct type syntax
@type t :: %__MODULE__{
name: String.t(),
email: String.t() | nil
}
Why: Struct types in Elixir use field: type syntax, not required/optional. The required()/optional() syntax is for plain maps. Mixing them up confuses Dialyzer and readers.
7. Keyword List Types for Options
Source: lib/logger/lib/logger.ex#L509
What it does: Defines option types as keyword lists with specific key-value constraints, sometimes nested.
Why: Many Elixir APIs accept keyword options. Typing them precisely documents valid options and their expected value types without forcing users to read function documentation.
Anti-pattern: Typing options as keyword() (untyped keyword list), which provides zero guidance.
Code example:
@type configure_opts :: [
level: level(),
translator_inspect_opts: Inspect.Opts.t(),
sync_threshold: non_neg_integer(),
discard_threshold: non_neg_integer(),
truncate: non_neg_integer() | :infinity,
utc_log: boolean()
]
@type formatter_opts :: [
colors: [
enabled: boolean(),
debug: atom(),
info: atom(),
warning: atom(),
error: atom()
],
format: String.t() | {module(), atom()},
metadata: :all | [atom()],
truncate: pos_integer() | :infinity,
utc_log: boolean()
]
When to Use
Triggers:
- A function accepts a keyword list of options (the
opts \\ []pattern) - You have more than 2-3 options and want to document valid keys and their types
- The options type is reused across multiple functions in the module
Example — before:
@spec start_link(keyword()) :: GenServer.on_start()
Example — after:
@type start_opts :: [
name: GenServer.name(),
timeout: pos_integer(),
debug: [:trace | :log | :statistics]
]
@spec start_link(start_opts()) :: GenServer.on_start()
When NOT to Use
Don't use this when:
- The function takes 1-2 simple options that are obvious from the function's
@doc - The options are passed through to another function unchanged (type the pass-through, not the intermediate)
- You're defining it but Dialyzer can't actually check keyword list shapes deeply — don't give false confidence
Over-application example:
@type greet_opts :: [name: String.t()]
@spec greet(greet_opts()) :: String.t()
def greet(opts \\ []) do
"Hello, #{opts[:name] || "world"}"
end
Better alternative:
@spec greet(name :: String.t()) :: String.t()
def greet(name \\ "world") do
"Hello, #{name}"
end
Why: A single optional value is better expressed as a default argument. Keyword list types shine when you have many options with different types. One option wrapped in a keyword list is over-engineering.
8. Parameterized Types (t/1)
Source: lib/elixir/lib/enum.ex#L58 (Enumerable protocol)
What it does: Defines a parameterized type t(_element) that allows expressing the element type of an enumerable in function specs.
Why: Enables downstream functions to express type flow: "takes an enumerable of integers, returns an enumerable of strings." The parameter communicates element type even though the protocol dispatch erases it at runtime.
Anti-pattern: Always using t() without parameters when the element type is known and useful for documentation.
Code example:
@typedoc """
An enumerable of elements of type `element`.
This type is equivalent to `t:t/0` but is especially useful for documentation.
For example, imagine you define a function that expects an enumerable of
integers and returns an enumerable of strings:
@spec integers_to_strings(Enumerable.t(integer())) :: Enumerable.t(String.t())
def integers_to_strings(integers) do
Stream.map(integers, &Integer.to_string/1)
end
"""
@typedoc since: "1.14.0"
@type t(_element) :: t()
When to Use
Triggers:
- You define a container/collection type and want specs to express what's inside
- Functions transform the element type (e.g., map, filter, convert)
- Your library exports a generic data structure (queue, tree, ring buffer)
Example — before:
@type queue :: %__MODULE__{items: list()}
@spec push(queue(), term()) :: queue()
@spec pop(queue()) :: {term(), queue()}
Example — after:
@type t(element) :: %__MODULE__{items: [element]}
@spec push(t(element), element) :: t(element) when element: term()
@spec pop(t(element)) :: {element, t(element)} when element: term()
When NOT to Use
Don't use this when:
- The container always holds a fixed type (e.g., a
TokenBucketthat only holds integers — just useinteger()directly) - Dialyzer can't actually track the parameter through your implementation (it mostly can't for complex cases) — the value is documentation-only
- The parameterization would require more than one type variable and the spec becomes unreadable
Over-application example:
@type config(value_type) :: %{key: atom(), value: value_type}
# But it's always used with String.t() in practice:
@spec get_config() :: config(String.t())
@spec set_config(config(String.t())) :: :ok
Better alternative:
@type config :: %{key: atom(), value: String.t()}
@spec get_config() :: config()
@spec set_config(config()) :: :ok
Why: Parameterized types add value when the parameter actually varies across usage sites. If every call site uses the same concrete type, the parameter is abstraction without benefit.
9. Named Parameters in Specs (:: annotation)
Source: lib/elixir/lib/gen_server.ex#L577, lib/elixir/lib/supervisor.ex#L562
What it does: Uses name :: type syntax in callback/spec parameter positions to give meaningful names to parameters.
Why: init(init_arg :: term) is vastly more readable than init(term). The name serves as inline documentation within the spec itself.
Anti-pattern: Writing specs with only types and no parameter names, especially for callbacks that users must implement.
Code example:
@callback init(init_arg :: term) ::
{:ok, state}
| {:ok, state, timeout | :hibernate | {:continue, continue_arg :: term}}
| :ignore
| {:stop, reason :: term}
when state: term
@callback terminate(reason, state :: term) :: term
when reason: :normal | :shutdown | {:shutdown, term} | term
When to Use
Triggers:
- A parameter's type alone doesn't convey its purpose (e.g.,
term(),String.t(),integer()) - You're defining a callback that other developers must implement
- The function has multiple parameters of the same type
Example — before:
@spec transfer(String.t(), String.t(), pos_integer()) :: {:ok, reference()} | {:error, term()}
Example — after:
@spec transfer(from_account :: String.t(), to_account :: String.t(), amount :: pos_integer()) ::
{:ok, reference()} | {:error, term()}
When NOT to Use
Don't use this when:
- The type itself is descriptive enough (e.g.,
pid(),module(),boolean()) - The function has a single parameter and the function name makes it obvious
- The name would just repeat the type (e.g.,
string :: String.t())
Over-application example:
@spec alive?(pid :: pid()) :: boolean()
@spec start(module :: module()) :: {:ok, pid()}
Better alternative:
@spec alive?(pid()) :: boolean()
@spec start(module()) :: {:ok, pid()}
Why: pid() and module() are self-documenting types. Adding pid :: pid() is like writing # increments x above x += 1. The annotation should add information the type alone doesn't convey.
10. @typedoc since: Annotation
Source: lib/elixir/lib/supervisor.ex#L669, lib/elixir/lib/enum.ex#L72
What it does: Attaches a since: metadata annotation to @typedoc indicating when a type was introduced.
Why: Helps library consumers know version requirements. The pattern mirrors @doc since: used on functions.
Anti-pattern: Omitting since: on types added after the initial release, leaving users guessing about compatibility.
Code example:
@typedoc since: "1.16.0"
@type module_spec :: {module(), args :: term()} | module()
@typedoc since: "1.14.0"
@type t(_element) :: t()
When to Use
Triggers:
- You're adding a new public type to an existing library (post-1.0 release)
- Your library follows semantic versioning and users need to know minimum version requirements
- You're maintaining a changelog and want types to be traceable to releases
Example — before:
@typedoc "A validated email address"
@type email :: String.t()
Example — after:
@typedoc since: "2.3.0"
@typedoc "A validated email address"
@type email :: String.t()
When NOT to Use
Don't use this when:
- The type has existed since the library's initial release (no version ambiguity)
- You're in an application (not a library) where version tracking of types is meaningless
- The library doesn't follow semver or publish versioned docs
Over-application example:
# In a Phoenix app's context module
defmodule MyApp.Accounts do
@typedoc since: "0.1.0"
@type user_id :: pos_integer()
end
Better alternative:
defmodule MyApp.Accounts do
@type user_id :: pos_integer()
end
Why: since: annotations are for library consumers checking compatibility across versions. Application code doesn't have "consumers" checking which version introduced a type — it's all deployed together.
Decision Tree
- If you are defining a public
@typethat appears in any@specor callback → Public Type with @typedoc - If a type is used only internally for recursion or DRYing up repeated expressions → Private Types with @typep
- If you want to hide internal representation and force consumers to use accessor functions → @opaque Types
- If a function can return multiple distinct shapes (tagged tuples, atoms) → Union Types in @spec Return Values
- If the return type depends on the input type (generic/polymorphic function) →
whenConstraints in Specs - If you accept a map with a mix of mandatory and optional keys → Map Types with required/optional Keys
- If a function accepts a keyword list of options and you want to document valid keys → Keyword List Types for Options
- If you define a container type and want specs to express what element type is inside → Parameterized Types (t/1)
- If a parameter's type alone does not convey its purpose → Named Parameters in Specs
- If you are adding a new public type to an existing library post-1.0 → @typedoc since: Annotation