Files
aweiker 10218813d3 docs: backfill TOC + decision trees, fix review findings
- 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"
2026-05-01 22:13:35 -07:00

28 KiB
Raw Permalink Blame History

Typespecs Patterns

Patterns extracted from the Elixir standard library source code.

Contents

  1. Public Type with @typedoc
  2. Private Types with @typep
  3. @opaque Types (Protocol t())
  4. Union Types in @spec Return Values
  5. when Constraints in Specs
  6. Map Types with required/optional Keys
  7. Keyword List Types for Options
  8. Parameterized Types (t/1)
  9. Named Parameters in Specs (:: annotation)
  10. @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 @type that appears in any @spec or 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, 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

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 @type instead

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: term with 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, 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()
      }

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 defstruct and @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 TokenBucket that only holds integers — just use integer() 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