From 1a934eb2e39db0e4551d591412f804457b0ce457 Mon Sep 17 00:00:00 2001 From: Aaron Weiker Date: Thu, 30 Apr 2026 05:23:28 -0700 Subject: [PATCH] docs: add when/when-not to typespecs + documentation + behaviours --- patterns/behaviours.md | 483 +++++++++++++++++++++++++++ patterns/documentation.md | 673 ++++++++++++++++++++++++++++++++++++++ patterns/typespecs.md | 491 +++++++++++++++++++++++++++ 3 files changed, 1647 insertions(+) diff --git a/patterns/behaviours.md b/patterns/behaviours.md index a48dc22..3ea9454 100644 --- a/patterns/behaviours.md +++ b/patterns/behaviours.md @@ -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. diff --git a/patterns/documentation.md b/patterns/documentation.md index 75e0196..3ec4c40 100644 --- a/patterns/documentation.md +++ b/patterns/documentation.md @@ -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. diff --git a/patterns/typespecs.md b/patterns/typespecs.md index e771e55..ba4860b 100644 --- a/patterns/typespecs.md +++ b/patterns/typespecs.md @@ -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.