Files
elixir-patterns/patterns/typespecs.md
T
Aaron Weiker 4ea9a884aa docs: idiomatic Elixir and Phoenix patterns with source citations
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.
2026-04-29 22:50:12 -07:00

10 KiB
Raw Blame History

Typespecs Patterns

Patterns extracted from the Elixir standard library source code.


1. Public Type with @typedoc

Source: lib/elixir/lib/gen_server.ex lines 862896

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 150168 (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 577583, 647658

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 602607, 644652

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 509531

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 5873 (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 669670, 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()