# 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:** ```elixir @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:** ```elixir @type status :: :pending | :active | :suspended | :terminated ``` **Example — after:** ```elixir @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:** ```elixir @typedoc "A boolean value" @type enabled :: boolean() @typedoc "The name" @type name :: String.t() ``` **Better alternative:** ```elixir # 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` 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:** ```elixir @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:** ```elixir # 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:** ```elixir @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:** ```elixir 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:** ```elixir 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` 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:** ```elixir # 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:** ```elixir @type t :: %__MODULE__{items: list(), size: non_neg_integer()} # Users can (and will) do: queue.items |> Enum.reverse() ``` **Example — after:** ```elixir @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:** ```elixir 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:** ```elixir 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` 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:** ```elixir @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:** ```elixir @spec fetch(key :: String.t()) :: term() ``` **Example — after:** ```elixir @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:** ```elixir @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:** ```elixir @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` 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:** ```elixir @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:** ```elixir @spec get_or_default(map(), atom(), term()) :: term() ``` **Example — after:** ```elixir @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:** ```elixir @spec log(message) :: :ok when message: String.t() ``` **Better alternative:** ```elixir @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` 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:** ```elixir @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:** ```elixir @spec connect(map()) :: {:ok, pid()} ``` **Example — after:** ```elixir @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:** ```elixir # Defining required/optional for a struct @type t :: %__MODULE__{ required(:name) => String.t(), optional(:email) => String.t() } ``` **Better alternative:** ```elixir # 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` 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:** ```elixir @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:** ```elixir @spec start_link(keyword()) :: GenServer.on_start() ``` **Example — after:** ```elixir @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:** ```elixir @type greet_opts :: [name: String.t()] @spec greet(greet_opts()) :: String.t() def greet(opts \\ []) do "Hello, #{opts[:name] || "world"}" end ``` **Better alternative:** ```elixir @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` 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:** ```elixir @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:** ```elixir @type queue :: %__MODULE__{items: list()} @spec push(queue(), term()) :: queue() @spec pop(queue()) :: {term(), queue()} ``` **Example — after:** ```elixir @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:** ```elixir @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:** ```elixir @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` 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:** ```elixir @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:** ```elixir @spec transfer(String.t(), String.t(), pos_integer()) :: {:ok, reference()} | {:error, term()} ``` **Example — after:** ```elixir @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:** ```elixir @spec alive?(pid :: pid()) :: boolean() @spec start(module :: module()) :: {:ok, pid()} ``` **Better alternative:** ```elixir @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` 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:** ```elixir @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:** ```elixir @typedoc "A validated email address" @type email :: String.t() ``` **Example — after:** ```elixir @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:** ```elixir # In a Phoenix app's context module defmodule MyApp.Accounts do @typedoc since: "0.1.0" @type user_id :: pos_integer() end ``` **Better alternative:** ```elixir 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.