Extracted patterns, conventions, and code smells directly from the Elixir and Phoenix source code with file path and line number citations. Covers: GenServer, error handling, data transforms, process design, testing, documentation, typespecs, macros, behaviours, module organization, Phoenix-specific patterns, framework deviations, and anti-patterns.
10 KiB
Typespecs Patterns
Patterns extracted from the Elixir standard library source code.
1. Public Type with @typedoc
Source: lib/elixir/lib/gen_server.ex lines 862–896
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}
2. Private Types with @typep
Source: lib/elixir/lib/macro.ex lines 84, 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]}
3. @opaque Types (Protocol t())
Source: lib/elixir/lib/protocol.ex lines 150–168 (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
4. Union Types in @spec Return Values
Source: lib/elixir/lib/gen_server.ex lines 577–583, 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
5. when Constraints in Specs
Source: lib/elixir/lib/kernel.ex lines 635, 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
6. Map Types with required/optional Keys
Source: lib/elixir/lib/supervisor.ex lines 602–607, 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()
}
7. Keyword List Types for Options
Source: lib/logger/lib/logger.ex lines 509–531
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()
]
8. Parameterized Types (t/1)
Source: lib/elixir/lib/enum.ex lines 58–73 (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()
9. Named Parameters in Specs (:: annotation)
Source: lib/elixir/lib/gen_server.ex line 577, lib/elixir/lib/supervisor.ex line 562
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
10. @typedoc since: Annotation
Source: lib/elixir/lib/supervisor.ex lines 669–670, lib/elixir/lib/enum.ex line 72
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()