docs: add when/when-not to typespecs + documentation + behaviours

This commit is contained in:
Aaron Weiker
2026-04-30 05:23:28 -07:00
parent e81439e686
commit 1a934eb2e3
3 changed files with 1647 additions and 0 deletions
+673
View File
@@ -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.