docs: add when/when-not to typespecs + documentation + behaviours
This commit is contained in:
@@ -25,6 +25,66 @@ How behaviours are designed, implemented, and used in Elixir core and Phoenix.
|
||||
|
||||
**Anti-pattern:** Defining callbacks with `@callback handle_call(term, term, term) :: term` — provides zero guidance to implementors.
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You're defining a contract that multiple modules will implement differently
|
||||
- You want compile-time guarantees that implementors provide required functions
|
||||
- The return type has multiple valid shapes that implementors must choose between
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
# No formal contract — just "convention" in a README
|
||||
defmodule MyApp.PaymentGateway do
|
||||
def charge(amount, card), do: raise "implement me"
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyApp.PaymentGateway do
|
||||
@callback charge(amount :: pos_integer(), card :: card_token()) ::
|
||||
{:ok, transaction_id :: String.t()}
|
||||
| {:declined, reason :: String.t()}
|
||||
| {:error, :network_timeout | :invalid_card}
|
||||
when card_token: String.t()
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- There's only one implementation and no plans for more (just use a module)
|
||||
- The "contract" is so simple it's a single function with one return type — a protocol or simple module works better
|
||||
- You need runtime dispatch based on data type (use protocols instead)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Config do
|
||||
@callback get(key :: atom()) :: term()
|
||||
@callback put(key :: atom(), value :: term()) :: :ok
|
||||
end
|
||||
|
||||
# Only ever one implementation:
|
||||
defmodule MyApp.Config.Env do
|
||||
@behaviour MyApp.Config
|
||||
def get(key), do: Application.get_env(:my_app, key)
|
||||
def put(key, value), do: Application.put_env(:my_app, key, value)
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
# Just a module — no behaviour ceremony needed for a singleton
|
||||
defmodule MyApp.Config do
|
||||
def get(key), do: Application.get_env(:my_app, key)
|
||||
def put(key, value), do: Application.put_env(:my_app, key, value)
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** Behaviours add value through polymorphism — multiple implementations behind one contract. A behaviour with exactly one implementation is indirection without benefit. Add the behaviour when the second implementation arrives.
|
||||
|
||||
---
|
||||
|
||||
## 2. `@optional_callbacks` for Extensibility
|
||||
@@ -45,6 +105,65 @@ How behaviours are designed, implemented, and used in Elixir core and Phoenix.
|
||||
|
||||
**Anti-pattern:** Making all callbacks required when most have reasonable defaults — forces implementors to write boilerplate they don't need.
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- Your behaviour has callbacks where most implementors will use a default (e.g., `terminate/2`, `code_change/3`)
|
||||
- You want a minimal "get started" experience — implement one function, everything else works
|
||||
- Some callbacks are only needed for advanced use cases
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
defmodule MyApp.Worker do
|
||||
@callback init(args :: term()) :: {:ok, state :: term()}
|
||||
@callback handle_task(task :: term(), state :: term()) :: {:ok, state :: term()}
|
||||
@callback on_error(error :: term(), state :: term()) :: {:ok, state :: term()}
|
||||
@callback on_shutdown(reason :: term(), state :: term()) :: :ok
|
||||
end
|
||||
# Implementors MUST define all 4, even if on_error and on_shutdown are no-ops
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyApp.Worker do
|
||||
@callback init(args :: term()) :: {:ok, state :: term()}
|
||||
@callback handle_task(task :: term(), state :: term()) :: {:ok, state :: term()}
|
||||
@callback on_error(error :: term(), state :: term()) :: {:ok, state :: term()}
|
||||
@callback on_shutdown(reason :: term(), state :: term()) :: :ok
|
||||
|
||||
@optional_callbacks on_error: 2, on_shutdown: 2
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The callback is essential to correctness (if skipping it would break the system, it's required)
|
||||
- Every implementor WILL need to customize the behavior (making it optional hides a real requirement)
|
||||
- You have only 1-2 callbacks total — if they're all optional, why is it a behaviour?
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Serializer do
|
||||
@callback encode(term()) :: {:ok, binary()} | {:error, term()}
|
||||
@callback decode(binary()) :: {:ok, term()} | {:error, term()}
|
||||
|
||||
@optional_callbacks encode: 1, decode: 1 # Both optional?!
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Serializer do
|
||||
@callback encode(term()) :: {:ok, binary()} | {:error, term()}
|
||||
@callback decode(binary()) :: {:ok, term()} | {:error, term()}
|
||||
# Both are required — a serializer that can't encode or decode isn't a serializer
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** If ALL callbacks are optional, the behaviour provides no compile-time guarantees. At least one callback should be required to justify the behaviour's existence. Optional callbacks are for extensions, not the core contract.
|
||||
|
||||
---
|
||||
|
||||
## 3. `@behaviour` Declaration in `__using__`
|
||||
@@ -76,6 +195,77 @@ end
|
||||
|
||||
**Anti-pattern:** Requiring users to manually add both `use MyModule` AND `@behaviour MyModule`.
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- Your behaviour requires boilerplate that every implementor would write identically
|
||||
- You want `use MyBehaviour` to "just work" with compile-time callback verification
|
||||
- The behaviour has associated module attributes, process setup, or struct definitions
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
# Users must remember both steps
|
||||
defmodule MyWorker do
|
||||
@behaviour MyApp.Worker
|
||||
# Easy to forget @behaviour and lose compile-time checks
|
||||
def init(_), do: {:ok, %{}}
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyApp.Worker do
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Worker
|
||||
# Compile-time checks are automatic
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MyWorker do
|
||||
use MyApp.Worker
|
||||
def init(_), do: {:ok, %{}}
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The behaviour has no associated boilerplate — a bare `@behaviour` declaration is sufficient
|
||||
- You're creating a `use` macro that only sets `@behaviour` and nothing else (unnecessary indirection)
|
||||
- The module being "used" doesn't define callbacks (it's a utility, not a behaviour)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Formatter do
|
||||
@callback format(term()) :: String.t()
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Formatter
|
||||
# That's it — nothing else generated
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Formatter do
|
||||
@callback format(term()) :: String.t()
|
||||
end
|
||||
|
||||
# Users just add @behaviour directly — simpler, more explicit
|
||||
defmodule HtmlFormatter do
|
||||
@behaviour MyApp.Formatter
|
||||
def format(data), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `use` implies "this macro generates code for you." If it only sets `@behaviour`, the indirection hides what's happening without saving any work. Use `use` when there's actual code generation; use bare `@behaviour` when there isn't.
|
||||
|
||||
---
|
||||
|
||||
## 4. Default Implementations via `defoverridable`
|
||||
@@ -98,6 +288,72 @@ defoverridable child_spec: 1
|
||||
|
||||
**Anti-pattern:** Not using `defoverridable` — users who need custom behavior must bypass the `use` macro entirely.
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- The 90% case has an obvious default implementation (e.g., `child_spec/1`, `terminate/2`)
|
||||
- You want users to opt-in to customization rather than requiring it
|
||||
- The default is non-trivial enough that users shouldn't have to copy-paste it
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Plugin
|
||||
|
||||
# No default — every plugin must implement format_output/1
|
||||
# even though 90% just want to call inspect()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Plugin
|
||||
|
||||
def format_output(data), do: inspect(data, pretty: true)
|
||||
defoverridable format_output: 1
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The default implementation would be wrong for most cases (forces users to override = same as required)
|
||||
- The function is the core purpose of the behaviour (e.g., `handle_call` in GenServer has no default because the POINT is to define it)
|
||||
- You're providing a default that silently swallows errors or does nothing
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.EventHandler
|
||||
|
||||
def handle_event(_event, state), do: {:ok, state}
|
||||
defoverridable handle_event: 2
|
||||
end
|
||||
end
|
||||
# Now users can "implement" the behaviour without handling ANY events
|
||||
# Bugs hide because unhandled events silently succeed
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.EventHandler
|
||||
# handle_event/2 is required — no default
|
||||
# If you don't handle events, you don't need this behaviour
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** A default implementation that does nothing for the CORE callback creates a pit of failure — modules compile cleanly while silently dropping events. Defaults should be for auxiliary concerns (logging, shutdown, serialization), not the primary contract.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phoenix Channel: Behaviour + Process + Protocol
|
||||
@@ -135,6 +391,86 @@ end
|
||||
|
||||
**Anti-pattern:** Trying to encode runtime configuration in the behaviour contract itself, or conflating compile-time and runtime concerns.
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- Your behaviour involves a running process (GenServer, Agent, Task-like)
|
||||
- The module needs both compile-time contracts AND runtime configuration
|
||||
- Different options affect process lifecycle (timeouts, shutdown strategies, restart policies)
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
defmodule MyApp.Worker do
|
||||
@callback handle_job(job :: term()) :: :ok | {:error, term()}
|
||||
# No process semantics, no configuration — just a callback
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyApp.Worker do
|
||||
@callback init(args :: term()) :: {:ok, state :: term()}
|
||||
@callback handle_job(job :: term(), state :: term()) :: {:ok, state :: term()} | {:error, term(), state :: term()}
|
||||
|
||||
defmacro __using__(opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Worker
|
||||
@worker_timeout Keyword.get(unquote(opts), :timeout, 30_000)
|
||||
@worker_max_retries Keyword.get(unquote(opts), :max_retries, 3)
|
||||
|
||||
def child_spec(init_arg) do
|
||||
%{id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}, restart: :transient}
|
||||
end
|
||||
|
||||
defoverridable child_spec: 1
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The behaviour is purely functional (no process, no state) — keep it simple
|
||||
- You're conflating too many concerns (behaviour + process + config + routing) in one module
|
||||
- The "configuration" is better handled at runtime via application config rather than compile-time module attributes
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Formatter do
|
||||
@callback format(term()) :: String.t()
|
||||
|
||||
defmacro __using__(opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Formatter
|
||||
@formatter_timeout Keyword.get(unquote(opts), :timeout, 5000)
|
||||
|
||||
def child_spec(_) do
|
||||
%{id: __MODULE__, start: {__MODULE__, :start_link, []}}
|
||||
end
|
||||
|
||||
def start_link do
|
||||
GenServer.start_link(__MODULE__, [])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# A formatter doesn't need to be a process!
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Formatter do
|
||||
@callback format(term()) :: String.t()
|
||||
end
|
||||
|
||||
# Pure behaviour — implementors are just modules with a format/1 function
|
||||
# No process needed for a synchronous data transformation
|
||||
```
|
||||
|
||||
**Why:** Not everything needs to be a process. Adding GenServer semantics to a behaviour that does synchronous data transformation is over-engineering. Reserve process+behaviour combinations for things that genuinely need state, concurrency, or lifecycle management.
|
||||
|
||||
---
|
||||
|
||||
## 6. Callback Documentation Pattern
|
||||
@@ -172,6 +508,85 @@ This trio (doc + example + spec) gives implementors everything they need.
|
||||
|
||||
**Anti-pattern:** Defining callbacks without documentation — implementors have to read source code to understand when callbacks fire.
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You're defining a behaviour that users must implement
|
||||
- Each callback has non-obvious semantics (when it fires, what params mean, what returns cause)
|
||||
- The behaviour is public and will be implemented by people who didn't write it
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@callback on_connect(params :: map(), state :: term()) :: {:ok, term()} | {:error, term()}
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc """
|
||||
Called when a client establishes a new connection.
|
||||
|
||||
`params` contains the query parameters from the connection URL.
|
||||
`state` is initialized to the value returned by the transport's init.
|
||||
|
||||
Return `{:ok, state}` to accept the connection with updated state.
|
||||
Return `{:error, reason}` to reject — `reason` is sent to the client
|
||||
as the close frame payload.
|
||||
|
||||
## Example
|
||||
|
||||
def on_connect(%{"token" => token}, state) do
|
||||
case verify_token(token) do
|
||||
{:ok, user_id} -> {:ok, Map.put(state, :user_id, user_id)}
|
||||
:error -> {:error, :unauthorized}
|
||||
end
|
||||
end
|
||||
"""
|
||||
@callback on_connect(params :: map(), state :: term()) ::
|
||||
{:ok, state :: term()}
|
||||
| {:error, reason :: term()}
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The callback is internal and only your own code implements it
|
||||
- The callback name and spec are completely self-explanatory (`@callback format(String.t()) :: String.t()`)
|
||||
- You're writing a one-off behaviour for test mocking — extensive docs are wasted effort
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@doc """
|
||||
Converts the value to a string.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `value` — the value to convert (term)
|
||||
|
||||
## Returns
|
||||
|
||||
- `String.t()` — the converted string
|
||||
|
||||
## Examples
|
||||
|
||||
def to_string(123), do: "123"
|
||||
def to_string(:hello), do: "hello"
|
||||
|
||||
## Notes
|
||||
|
||||
This callback is required.
|
||||
"""
|
||||
@callback to_string(value :: term()) :: String.t()
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@doc "Converts `value` to its string representation for display."
|
||||
@callback to_string(value :: term()) :: String.t()
|
||||
```
|
||||
|
||||
**Why:** Documentation depth should match callback complexity. A single-purpose callback with one obvious return type needs one sentence, not a full reference page. Save detailed docs for callbacks with multiple return shapes and non-obvious triggering conditions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phoenix.Endpoint: Behaviour as Interface Contract
|
||||
@@ -194,3 +609,71 @@ end
|
||||
**Why:** The Endpoint uses `@behaviour` to define what an endpoint MUST provide (like `config/2`), then `__using__` generates the common implementation. The behaviour is the interface; the macro provides the default implementation.
|
||||
|
||||
**Anti-pattern:** Using only a behaviour without a `use` macro when significant boilerplate is required — forces every implementor to write the same code.
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- Your behaviour defines a contract AND requires significant generated code
|
||||
- The "interface" is simple but the implementation wiring is complex (plugs, routing, supervision)
|
||||
- Users of the behaviour shouldn't need to understand the plumbing — just implement callbacks
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
# User has to wire everything manually
|
||||
defmodule MyEndpoint do
|
||||
@behaviour Phoenix.Endpoint
|
||||
use Plug.Builder
|
||||
# ... 50 lines of boilerplate
|
||||
# Easy to get wrong
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyEndpoint do
|
||||
use Phoenix.Endpoint, otp_app: :my_app
|
||||
# All wiring generated — just configure and add plugs
|
||||
plug MyAppWeb.Router
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The generated code is minimal (just `@behaviour` — see pattern #3)
|
||||
- The magic is hard to debug when things go wrong (transparency > convenience)
|
||||
- Users need to understand what's generated to use the module correctly
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Validator do
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour MyApp.Validator
|
||||
import MyApp.Validator.DSL
|
||||
Module.register_attribute(__MODULE__, :validations, accumulate: true)
|
||||
@before_compile MyApp.Validator
|
||||
|
||||
# 40 lines of generated code for "validation framework"
|
||||
# Users need a PhD in macros to debug validation errors
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Validator do
|
||||
@callback validate(term()) :: :ok | {:error, [String.t()]}
|
||||
end
|
||||
|
||||
# Simple behaviour — implementors write plain Elixir
|
||||
defmodule UserValidator do
|
||||
@behaviour MyApp.Validator
|
||||
|
||||
def validate(%{name: name}) when byte_size(name) > 0, do: :ok
|
||||
def validate(_), do: {:error, ["name is required"]}
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** The more code a `use` macro generates, the harder it is to debug. If users regularly need to read the generated code to understand failures, the abstraction is leaking. Reserve heavy `use` macros for well-established patterns (GenServer, Endpoint, Channel) where the community has internalized the mental model.
|
||||
|
||||
@@ -50,6 +50,97 @@ defmodule GenServer do
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- Your module is the primary entry point for a concept (GenServer, Logger, Supervisor)
|
||||
- The module has more than 3-4 public functions with non-obvious relationships
|
||||
- Users need to understand configuration, lifecycle, or architecture before calling individual functions
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
defmodule MyApp.Cache do
|
||||
@moduledoc "A cache module."
|
||||
|
||||
def get(key), do: ...
|
||||
def put(key, value, opts), do: ...
|
||||
def invalidate(key), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyApp.Cache do
|
||||
@moduledoc """
|
||||
A write-through cache backed by ETS with configurable TTL.
|
||||
|
||||
## Usage
|
||||
|
||||
MyApp.Cache.put("user:1", user, ttl: :timer.minutes(5))
|
||||
MyApp.Cache.get("user:1") #=> {:ok, %User{}}
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure in your application config:
|
||||
|
||||
config :my_app, MyApp.Cache,
|
||||
max_size: 10_000,
|
||||
default_ttl: :timer.hours(1)
|
||||
|
||||
## Eviction
|
||||
|
||||
When `max_size` is reached, entries are evicted LRU-first...
|
||||
"""
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The module has 1-2 functions and the purpose is obvious from the module name
|
||||
- It's an internal/private module (`@moduledoc false` is better)
|
||||
- The module is a thin wrapper where function-level docs suffice
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.StringUtils do
|
||||
@moduledoc """
|
||||
# String Utilities
|
||||
|
||||
## Overview
|
||||
|
||||
This module provides string utility functions for the application.
|
||||
|
||||
## Architecture
|
||||
|
||||
Functions in this module operate on binaries and return binaries...
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration required.
|
||||
|
||||
## Examples
|
||||
|
||||
See individual function documentation.
|
||||
"""
|
||||
|
||||
def capitalize_words(str), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.StringUtils do
|
||||
@moduledoc "String transformation helpers for display formatting."
|
||||
|
||||
@doc "Capitalizes the first letter of each word."
|
||||
def capitalize_words(str), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** A utility module with a few pure functions doesn't need architecture docs, configuration sections, or a table of contents. Match the documentation depth to the module's complexity.
|
||||
|
||||
---
|
||||
|
||||
## 2. @doc with Sections and Examples
|
||||
@@ -72,6 +163,69 @@ Returns all the available levels.
|
||||
def levels(), do: @levels
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- The function has non-obvious behavior, edge cases, or preconditions
|
||||
- Users need to understand what happens with boundary inputs (nil, empty list, negative numbers)
|
||||
- The function is part of a public API that others will call without reading the source
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
def chunk(list, size), do: ...
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc """
|
||||
Splits `list` into chunks of the given `size`.
|
||||
|
||||
The last chunk may have fewer than `size` elements if the list
|
||||
length is not evenly divisible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> chunk([1, 2, 3, 4, 5], 2)
|
||||
[[1, 2], [3, 4], [5]]
|
||||
|
||||
iex> chunk([], 3)
|
||||
[]
|
||||
|
||||
"""
|
||||
def chunk(list, size), do: ...
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The function is private (use `# comments` for private function notes)
|
||||
- The function name + typespec are completely self-explanatory (e.g., `@spec pid() :: pid()`)
|
||||
- You're implementing a behaviour callback and want it hidden (`@impl true` sets `@doc false` automatically)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@doc """
|
||||
Returns the name.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_name(%User{name: "Alice"})
|
||||
"Alice"
|
||||
|
||||
"""
|
||||
@spec get_name(User.t()) :: String.t()
|
||||
def get_name(%User{name: name}), do: name
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@spec get_name(User.t()) :: String.t()
|
||||
def get_name(%User{name: name}), do: name
|
||||
```
|
||||
|
||||
**Why:** When the function name, spec, and implementation are all trivially obvious, a `@doc` that restates them adds maintenance burden without helping anyone. Save detailed docs for functions where the behavior isn't immediately obvious.
|
||||
|
||||
---
|
||||
|
||||
## 3. @doc since: Version Annotation
|
||||
@@ -99,6 +253,56 @@ def binary_slice(binary, start, size)
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You're adding a new public function to an existing library (anything post-1.0)
|
||||
- Your library publishes versioned documentation (via ExDoc/HexDocs)
|
||||
- Users may be on older versions and need to know when a function became available
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@doc "Compacts the list by removing nil values."
|
||||
def compact(list), do: Enum.reject(list, &is_nil/1)
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc since: "1.4.0"
|
||||
@doc "Compacts the list by removing nil values."
|
||||
def compact(list), do: Enum.reject(list, &is_nil/1)
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- You're writing application code (not a published library)
|
||||
- The function has existed since the library's first release
|
||||
- The library doesn't publish versioned docs or follow semver
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
# In a Phoenix controller — no one checks "which version of my app added this"
|
||||
defmodule MyAppWeb.UserController do
|
||||
@doc since: "0.1.0"
|
||||
def index(conn, _params), do: ...
|
||||
|
||||
@doc since: "0.2.0"
|
||||
def show(conn, %{"id" => id}), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyAppWeb.UserController do
|
||||
def index(conn, _params), do: ...
|
||||
def show(conn, %{"id" => id}), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `since:` annotations help library consumers check compatibility. Application code is deployed atomically — there's no concept of "which version of the app introduced this endpoint." Use git blame instead.
|
||||
|
||||
---
|
||||
|
||||
## 4. @doc guard: true Metadata
|
||||
@@ -131,6 +335,62 @@ Returns the absolute value of `number`.
|
||||
def abs(number) when is_number(number), do: ...
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- The function/macro is valid in guard clauses
|
||||
- You want tools (ExDoc, IEx) to programmatically identify guard-eligible functions
|
||||
- The function is a Kernel macro that users might try to use in guards
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@doc """
|
||||
Returns true if `term` is a non-empty binary.
|
||||
|
||||
Allowed in guard tests.
|
||||
"""
|
||||
defmacro is_non_empty_binary(term) do
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc """
|
||||
Returns true if `term` is a non-empty binary.
|
||||
"""
|
||||
@doc guard: true
|
||||
defmacro is_non_empty_binary(term) do
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The function is NOT valid in guard clauses (adding it would be misleading)
|
||||
- You're writing application code where no one filters functions by guard eligibility
|
||||
- The function calls other functions or has side effects (it can't be a guard)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@doc guard: true
|
||||
def valid_email?(email) do
|
||||
String.contains?(email, "@") # NOT guard-safe — calls String.contains?
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@doc "Checks if the string looks like an email address."
|
||||
def valid_email?(email) do
|
||||
String.contains?(email, "@")
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `@doc guard: true` is a semantic contract that the function works in guard position. Adding it to non-guard functions breaks tooling expectations and confuses users who try to use it in `when` clauses.
|
||||
|
||||
---
|
||||
|
||||
## 5. @doc false — Hiding from Documentation
|
||||
@@ -156,6 +416,57 @@ def init(counter) do
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- A function must be public for technical reasons (protocol dispatch, macro expansion) but isn't part of the user API
|
||||
- You're implementing callbacks with `@impl true` and don't want generated docs
|
||||
- The function is an internal hook that users should never call directly
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
# Users see this in docs and try to call it
|
||||
def __struct__(fields), do: ...
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc false
|
||||
def __struct__(fields), do: ...
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The function IS part of the public API (even if you think it's "obvious")
|
||||
- You want to discourage use but still document it (use `@doc deprecated:` instead)
|
||||
- You're hiding functions because you're too lazy to document them
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Repo do
|
||||
@doc false
|
||||
def get(schema, id), do: ...
|
||||
|
||||
@doc false
|
||||
def all(query), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Repo do
|
||||
@doc "Fetches a single record by primary key. Returns nil if not found."
|
||||
def get(schema, id), do: ...
|
||||
|
||||
@doc "Fetches all records matching the query."
|
||||
def all(query), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `@doc false` means "this function is not part of the public API." If users are expected to call it, it needs documentation. Hiding public API behind `@doc false` is a maintenance hazard — users will call undocumented functions and break on upgrades.
|
||||
|
||||
---
|
||||
|
||||
## 6. @moduledoc false — Hiding Modules
|
||||
@@ -177,6 +488,69 @@ defmodule MyApp.Internal.Helper do
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- The module exists purely for internal code organization
|
||||
- It's a protocol implementation module that users never reference directly
|
||||
- It's a migration, task, or generated module that shouldn't appear in docs
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
defmodule MyApp.Repo.Supervisor do
|
||||
# Internal supervisor — users never interact with this directly
|
||||
use Supervisor
|
||||
def start_link(opts), do: ...
|
||||
def init(opts), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
defmodule MyApp.Repo.Supervisor do
|
||||
@moduledoc false
|
||||
use Supervisor
|
||||
def start_link(opts), do: ...
|
||||
def init(opts), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The module has any function that users are expected to call
|
||||
- You're hiding it because documentation feels like too much work
|
||||
- The module is a behaviour that others will implement
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule MyApp.Telemetry do
|
||||
@moduledoc false # "It's just telemetry setup, nobody cares"
|
||||
|
||||
def setup do
|
||||
# Attaches telemetry handlers that affect app behavior
|
||||
:telemetry.attach_many(...)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Telemetry do
|
||||
@moduledoc """
|
||||
Telemetry event handlers for request metrics and error tracking.
|
||||
|
||||
Called from `Application.start/2`. Attaches handlers for:
|
||||
- `[:my_app, :request, :stop]` — request duration histograms
|
||||
- `[:my_app, :error]` — error counters
|
||||
"""
|
||||
def setup, do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** If a module affects application behavior and other developers need to understand it during debugging or extension, it needs documentation. `@moduledoc false` should be reserved for truly mechanical/generated code.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mermaid Diagrams in Documentation
|
||||
@@ -206,6 +580,70 @@ graph BT
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You're documenting a multi-component architecture (client-server, pipeline, state machine)
|
||||
- The relationship between components is spatial or sequential and hard to express in prose
|
||||
- ExDoc is your documentation renderer (it supports Mermaid natively)
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
The pipeline processes events through three stages:
|
||||
first the parser, then the transformer, then the loader.
|
||||
The parser sends to transformer, transformer sends to loader.
|
||||
Errors at any stage go to the error handler.
|
||||
"""
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
Event processing pipeline with three stages.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Parser -->|events| Transformer
|
||||
Transformer -->|records| Loader
|
||||
Parser & Transformer & Loader -.->|errors| ErrorHandler
|
||||
```
|
||||
|
||||
## Stages
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The module's architecture is simple enough that a one-sentence description suffices
|
||||
- Your docs aren't rendered by a tool that supports Mermaid (raw markdown viewers won't render it)
|
||||
- The diagram would be trivial (one box, one arrow) — a sentence is clearer
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
Wraps an integer counter.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
User -->|increment| Counter
|
||||
Counter -->|value| User
|
||||
```
|
||||
"""
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
A simple integer counter. Call `increment/1` to add, `value/1` to read.
|
||||
"""
|
||||
```
|
||||
|
||||
**Why:** Diagrams add cognitive overhead. If the reader can understand the architecture faster from a sentence than from parsing a diagram, the diagram is noise. Reserve diagrams for genuinely complex interactions.
|
||||
|
||||
---
|
||||
|
||||
## 8. Admonition Blocks in Documentation
|
||||
@@ -234,6 +672,75 @@ graph BT
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- There's critical information that users MUST notice (breaking changes, security implications, required setup)
|
||||
- You need to document what `use MyModule` injects (the OTP convention)
|
||||
- A common mistake or foot-gun deserves visual prominence
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
...
|
||||
Note: calling process/1 with untrusted input can execute arbitrary code.
|
||||
Make sure to validate inputs first.
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
...
|
||||
|
||||
> #### Security Warning {: .warning}
|
||||
>
|
||||
> `process/1` evaluates its input. Never pass untrusted user input
|
||||
> directly — always validate and sanitize first.
|
||||
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The information isn't actually critical (don't cry wolf)
|
||||
- You're using admonitions for every paragraph (they lose impact)
|
||||
- Your rendering target doesn't support admonition syntax (it'll render as ugly blockquotes)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
> #### Overview {: .info}
|
||||
>
|
||||
> This module provides helper functions.
|
||||
|
||||
> #### Usage {: .info}
|
||||
>
|
||||
> Call the functions with the appropriate arguments.
|
||||
|
||||
> #### Note {: .neutral}
|
||||
>
|
||||
> All functions return their results.
|
||||
"""
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@moduledoc """
|
||||
Helper functions for string formatting.
|
||||
|
||||
## Usage
|
||||
|
||||
MyHelpers.format_name("alice") #=> "Alice"
|
||||
"""
|
||||
```
|
||||
|
||||
**Why:** Admonitions are visual interrupts — they break reading flow to demand attention. When everything is an admonition, nothing is. Reserve them for information where missing it would cause real problems.
|
||||
|
||||
---
|
||||
|
||||
## 9. @doc deprecated: Soft Deprecation
|
||||
@@ -261,6 +768,56 @@ def size(keyword) do
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You want to signal "prefer the alternative" without emitting compiler warnings
|
||||
- The function still works fine but a better API exists
|
||||
- You're planning a future hard deprecation and want to give users a migration window
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@doc "Fetches a user by ID. Deprecated: use Accounts.get_user/1."
|
||||
def fetch_user(id), do: ...
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc deprecated: "Use Accounts.get_user/1 instead"
|
||||
@doc "Fetches a user by ID."
|
||||
def fetch_user(id), do: ...
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- You actually want compiler warnings (use `@deprecated "reason"` instead)
|
||||
- The function is being removed in the next release (hard deprecation is appropriate)
|
||||
- The function is still the recommended approach (don't pre-deprecate)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
# Deprecating before the replacement exists
|
||||
@doc deprecated: "Will be replaced by new_process/2 in v3.0"
|
||||
def process(input) do
|
||||
# new_process/2 doesn't exist yet!
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
# Wait until the replacement is available, then deprecate
|
||||
def process(input), do: ...
|
||||
|
||||
# In v3.0, after new_process/2 ships:
|
||||
@doc deprecated: "Use new_process/2 instead"
|
||||
def process(input), do: new_process(input, [])
|
||||
```
|
||||
|
||||
**Why:** Never deprecate a function before the recommended alternative exists and is documented. Users seeing "use X instead" need X to actually exist — otherwise you're creating confusion without offering a path forward.
|
||||
|
||||
---
|
||||
|
||||
## 10. Callback Documentation Convention
|
||||
@@ -301,6 +858,75 @@ if a call is performed against it.
|
||||
when reply: term, new_state: term, reason: term
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You're defining a behaviour that other modules will implement
|
||||
- Implementers need to know: when the callback fires, what all return values mean, and which callbacks are optional
|
||||
- The callback has multiple valid return shapes with different effects
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@callback handle_event(event :: term(), state :: term()) :: {:ok, term()} | {:error, term()}
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc """
|
||||
Invoked when an event is received from the event bus.
|
||||
|
||||
`event` is the decoded event payload. `state` is the handler's
|
||||
current state, initialized by `c:init/1`.
|
||||
|
||||
Returning `{:ok, new_state}` acknowledges the event and continues.
|
||||
Returning `{:skip, new_state}` skips without acknowledgment (redelivery possible).
|
||||
Returning `{:error, reason}` triggers the error handler configured in `start_link/1`.
|
||||
|
||||
This callback is required.
|
||||
"""
|
||||
@callback handle_event(event :: term(), state :: term()) ::
|
||||
{:ok, new_state :: term()}
|
||||
| {:skip, new_state :: term()}
|
||||
| {:error, reason :: term()}
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The callback has a single obvious return type and the name says it all
|
||||
- You're documenting internal callbacks that aren't part of a public behaviour
|
||||
- The behaviour is private to your application and only you implement it
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@doc """
|
||||
Called to format the value.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `value` - The value to format (term)
|
||||
|
||||
## Returns
|
||||
|
||||
- `String.t()` - The formatted string
|
||||
|
||||
## Examples
|
||||
|
||||
iex> format(:hello)
|
||||
"hello"
|
||||
"""
|
||||
@callback format(value :: term()) :: String.t()
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@doc "Formats `value` into a human-readable string for display."
|
||||
@callback format(value :: term()) :: String.t()
|
||||
```
|
||||
|
||||
**Why:** A callback with one parameter and one return type doesn't need a full reference manual. Match documentation depth to complexity — a one-liner with good naming is better than padded sections that add no information.
|
||||
|
||||
---
|
||||
|
||||
## 11. Documentation with Link References (c: and t: prefixes)
|
||||
@@ -326,3 +952,50 @@ that must be handled by the `c:handle_call/3` callback in the GenServer.
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- Your moduledoc or function doc references other functions, callbacks, or types in the same or other modules
|
||||
- Users would benefit from clicking through to related documentation
|
||||
- You have callbacks and functions with the same name that need disambiguation
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@doc """
|
||||
Starts the server. Calls init/1 internally.
|
||||
See the server type for accepted values.
|
||||
"""
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@doc """
|
||||
Starts the server. Calls `c:init/1` internally.
|
||||
See `t:server/0` for accepted name values.
|
||||
"""
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- You're writing documentation that won't be rendered by ExDoc (plain README, comments)
|
||||
- The reference is to a well-known Erlang/Elixir function that readers will recognize without a link
|
||||
- Over-linking makes the prose unreadable (every other word is a link)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@doc """
|
||||
Returns `t:boolean/0` indicating if the `t:pid/0` from `Kernel.self/0`
|
||||
matches the `t:pid/0` stored in `t:state/0` field `:owner`.
|
||||
"""
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@doc """
|
||||
Returns `true` if the calling process is the owner of this resource.
|
||||
"""
|
||||
```
|
||||
|
||||
**Why:** Link references should aid navigation, not turn documentation into hypertext soup. Link types and callbacks that users might need to look up; don't link primitive types or universally known functions.
|
||||
|
||||
@@ -39,6 +39,54 @@ 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
|
||||
@@ -78,6 +126,66 @@ call.
|
||||
@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())
|
||||
@@ -105,6 +213,58 @@ 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
|
||||
@@ -137,6 +297,60 @@ end
|
||||
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
|
||||
@@ -159,6 +373,43 @@ end
|
||||
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
|
||||
@@ -191,6 +442,59 @@ end
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 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
|
||||
@@ -229,6 +533,57 @@ end
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
### 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)
|
||||
@@ -261,6 +616,56 @@ integers and returns an enumerable of strings:
|
||||
@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)
|
||||
@@ -286,6 +691,46 @@ integers and returns an enumerable of strings:
|
||||
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
|
||||
@@ -306,3 +751,49 @@ integers and returns an enumerable of strings:
|
||||
@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.
|
||||
|
||||
Reference in New Issue
Block a user