Compare commits
21 Commits
main
..
4bc7f3b357
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bc7f3b357 | |||
| 5f62dd0bf1 | |||
| 9ff22d2eed | |||
| e6fbfced96 | |||
| e51f491b6e | |||
| 46fe9c23c9 | |||
| 7bca84f906 | |||
| be7eeb0d63 | |||
| f5007e22e9 | |||
| e2b5ba487c | |||
| 44c61840df | |||
| 9a94765ea2 | |||
| 8f606d40d7 | |||
| cb94a157a1 | |||
| f23623250e | |||
| 29b91bead6 | |||
| 1a934eb2e3 | |||
| e81439e686 | |||
| a1eebb48a5 | |||
| 2e7a822b6b | |||
| 4ea9a884aa |
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Aaron Weiker
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
# Behaviour Patterns
|
||||||
|
|
||||||
|
How behaviours are designed, implemented, and used in Elixir core and Phoenix.
|
||||||
|
|
||||||
|
## 1. Behaviour Definition with `@callback`
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/gen_server.ex#L577](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L577) (all callback definitions)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
@callback init(init_arg :: term) ::
|
||||||
|
{:ok, state}
|
||||||
|
| {:ok, state, timeout | :hibernate | {:continue, continue_arg}}
|
||||||
|
| :ignore
|
||||||
|
| {:stop, reason :: any}
|
||||||
|
|
||||||
|
@callback handle_call(request :: term, from, state :: term) ::
|
||||||
|
{:reply, reply, new_state}
|
||||||
|
| {:noreply, new_state}
|
||||||
|
| {:stop, reason, reply, new_state}
|
||||||
|
| {:stop, reason, new_state}
|
||||||
|
when reply: term, new_state: term, reason: term
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Callbacks with full type unions document every valid return. Named parameters (`init_arg`, `request`, `state`) serve as documentation. The `when` clause defines type variables used across the union.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/channel.ex#L442](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L442)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
@optional_callbacks handle_in: 3,
|
||||||
|
handle_out: 3,
|
||||||
|
handle_info: 2,
|
||||||
|
handle_call: 3,
|
||||||
|
handle_cast: 2,
|
||||||
|
code_change: 3,
|
||||||
|
terminate: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Only `join/3` is required for a Channel. Everything else has sensible defaults. This keeps the minimum implementation surface small — a Channel that just joins and broadcasts needs only one function.
|
||||||
|
|
||||||
|
**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__`
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/channel.ex#L450](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L450)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmacro __using__(opts \\ []) do
|
||||||
|
quote do
|
||||||
|
opts = unquote(opts)
|
||||||
|
@behaviour unquote(__MODULE__)
|
||||||
|
@on_definition unquote(__MODULE__)
|
||||||
|
@before_compile unquote(__MODULE__)
|
||||||
|
...
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/gen_server.ex#L836](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L836)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
quote location: :keep, bind_quoted: [opts: opts] do
|
||||||
|
@behaviour GenServer
|
||||||
|
...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Setting `@behaviour` inside `use` means users get compile-time warnings about missing callbacks automatically. They don't need to know about the behaviour mechanism — `use Phoenix.Channel` handles it.
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/gen_server.ex#L849](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L849)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def child_spec(init_arg) do
|
||||||
|
default = %{
|
||||||
|
id: __MODULE__,
|
||||||
|
start: {__MODULE__, :start_link, [init_arg]}
|
||||||
|
}
|
||||||
|
Supervisor.child_spec(default, unquote(Macro.escape(opts)))
|
||||||
|
end
|
||||||
|
|
||||||
|
defoverridable child_spec: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `defoverridable` provides a working default that users CAN customize but don't HAVE to. The generated function works for the 90% case. The 10% can override it.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/channel.ex#L364](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L364) (full callback set)
|
||||||
|
|
||||||
|
The Channel behaviour combines:
|
||||||
|
1. **Required callback:** `join/3` (authorization gate)
|
||||||
|
2. **Optional callbacks:** `handle_in/3`, `handle_info/2`, etc. (event handlers)
|
||||||
|
3. **Process semantics:** Each channel is a GenServer (line 476-479)
|
||||||
|
4. **Configuration via module attributes:** `@phoenix_log_join`, `@phoenix_hibernate_after`
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# From __using__ — configures the process
|
||||||
|
@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000)
|
||||||
|
@phoenix_shutdown Keyword.get(opts, :shutdown, 5000)
|
||||||
|
|
||||||
|
def child_spec(init_arg) do
|
||||||
|
%{
|
||||||
|
id: __MODULE__,
|
||||||
|
start: {__MODULE__, :start_link, [init_arg]},
|
||||||
|
shutdown: @phoenix_shutdown,
|
||||||
|
restart: :temporary
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_link(triplet) do
|
||||||
|
GenServer.start_link(Phoenix.Channel.Server, triplet,
|
||||||
|
hibernate_after: @phoenix_hibernate_after
|
||||||
|
)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The Channel behaviour demonstrates layering — it's a behaviour (compile-time contract), a process (runtime entity), and configurable (via options to `use`). Each concern is handled by the appropriate mechanism.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/channel.ex#L350](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L350) (join callback doc)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
@doc """
|
||||||
|
Handle channel joins by `topic`.
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
def join("room:lobby", payload, socket) do
|
||||||
|
if authorized?(payload) do
|
||||||
|
{:ok, socket}
|
||||||
|
else
|
||||||
|
{:error, %{reason: "unauthorized"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
@callback join(topic :: binary, payload :: payload, socket :: Socket.t()) ::
|
||||||
|
{:ok, Socket.t()}
|
||||||
|
| {:ok, reply :: payload, Socket.t()}
|
||||||
|
| {:error, reason :: map}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Every callback gets:
|
||||||
|
1. A `@doc` explaining when it's called and what it should do
|
||||||
|
2. A concrete example
|
||||||
|
3. The full type spec with all valid returns
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/endpoint.ex#L408](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/endpoint.ex#L408)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmacro __using__(opts) do
|
||||||
|
quote do
|
||||||
|
@behaviour Phoenix.Endpoint
|
||||||
|
|
||||||
|
unquote(config(opts))
|
||||||
|
unquote(pubsub())
|
||||||
|
unquote(plug())
|
||||||
|
unquote(server())
|
||||||
|
end
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- PATTERN_COMPLETE -->
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,593 @@
|
|||||||
|
# Module Organization Patterns
|
||||||
|
|
||||||
|
How modules are structured, named, and organized in Elixir core and Phoenix.
|
||||||
|
|
||||||
|
## 1. One Module per Concept, Nested for Sub-Concepts
|
||||||
|
|
||||||
|
**Source:** `lib/elixir/lib/` directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gen_server.ex — GenServer (the main module)
|
||||||
|
task.ex — Task (the main module)
|
||||||
|
task/supervised.ex — Task.Supervised (internal implementation)
|
||||||
|
supervisor.ex — Supervisor (the main module)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source:** `lib/phoenix/` directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
channel.ex — Phoenix.Channel
|
||||||
|
channel/ — Phoenix.Channel.* submodules
|
||||||
|
server.ex — Phoenix.Channel.Server
|
||||||
|
router.ex — Phoenix.Router
|
||||||
|
router/ — Phoenix.Router.* submodules
|
||||||
|
route.ex — Phoenix.Router.Route
|
||||||
|
scope.ex — Phoenix.Router.Scope
|
||||||
|
resource.ex — Phoenix.Router.Resource
|
||||||
|
endpoint.ex — Phoenix.Endpoint
|
||||||
|
endpoint/ — Phoenix.Endpoint.* submodules
|
||||||
|
supervisor.ex — Phoenix.Endpoint.Supervisor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The parent module is the public API. Submodules handle implementation details. File layout mirrors module hierarchy — `Phoenix.Router.Route` lives in `router/route.ex`.
|
||||||
|
|
||||||
|
**Anti-pattern:** Putting all modules in a flat directory, or nesting too deeply (more than 3 levels is usually a sign of over-engineering).
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Your module has grown beyond ~300 lines with distinct sub-responsibilities
|
||||||
|
- External code only needs the parent module but implementation is complex
|
||||||
|
- You find yourself prefixing private functions with a concept name (e.g., `scope_push`, `scope_pop`)
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
# Everything crammed into one flat module
|
||||||
|
defmodule MyApp.Router do
|
||||||
|
# 800 lines mixing route compilation, scope tracking, and helper generation
|
||||||
|
def compile_route(...), do: # ...
|
||||||
|
def push_scope(...), do: # ...
|
||||||
|
def pop_scope(...), do: # ...
|
||||||
|
def generate_helper(...), do: # ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
# Parent module is the public API
|
||||||
|
defmodule MyApp.Router do
|
||||||
|
# Public API delegates to focused submodules
|
||||||
|
def compile(routes), do: MyApp.Router.Compiler.compile(routes)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Submodules handle implementation
|
||||||
|
defmodule MyApp.Router.Compiler do
|
||||||
|
@moduledoc false
|
||||||
|
# ...
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule MyApp.Router.Scope do
|
||||||
|
@moduledoc false
|
||||||
|
# ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The module is small and cohesive (< 200 lines)
|
||||||
|
- Nesting would exceed 3 levels (`A.B.C.D` is usually too deep)
|
||||||
|
- The "submodule" has its own independent public API (make it a sibling instead)
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# Over-nesting a simple utility
|
||||||
|
defmodule MyApp.Utils.String.Formatting.Case do
|
||||||
|
def upcase(s), do: String.upcase(s)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule MyApp.StringUtils do
|
||||||
|
def upcase(s), do: String.upcase(s)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Nesting should reflect genuine conceptual hierarchy. If you're creating submodules for 2-3 functions that don't have independent complexity, you're adding navigational overhead without architectural benefit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Public API at the Top, Private Functions at the Bottom
|
||||||
|
|
||||||
|
**Source:** `lib/elixir/lib/agent.ex` (full module structure)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule Agent do
|
||||||
|
@moduledoc "..."
|
||||||
|
|
||||||
|
# Types
|
||||||
|
@type on_start :: ...
|
||||||
|
@type name :: ...
|
||||||
|
@type agent :: ...
|
||||||
|
@type state :: ...
|
||||||
|
|
||||||
|
# child_spec (for supervisors)
|
||||||
|
def child_spec(arg) do ... end
|
||||||
|
|
||||||
|
# __using__ macro
|
||||||
|
defmacro __using__(opts) do ... end
|
||||||
|
|
||||||
|
# Public API
|
||||||
|
def start_link(fun, options \\ []) do ... end
|
||||||
|
def start(fun, options \\ []) do ... end
|
||||||
|
def get(agent, fun, timeout \\ 5000) do ... end
|
||||||
|
def get_and_update(agent, fun, timeout \\ 5000) do ... end
|
||||||
|
def update(agent, fun, timeout \\ 5000) do ... end
|
||||||
|
def cast(agent, fun) do ... end
|
||||||
|
def stop(agent, reason \\ :normal, timeout \\ :infinity) do ... end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The order:
|
||||||
|
1. `@moduledoc`
|
||||||
|
2. Types
|
||||||
|
3. `child_spec` / `__using__`
|
||||||
|
4. `start_link` / `start` (lifecycle)
|
||||||
|
5. Public API functions (alphabetical or logical grouping)
|
||||||
|
6. `stop` (lifecycle end)
|
||||||
|
|
||||||
|
**Anti-pattern:** Mixing private helpers between public functions, or putting `start_link` at the bottom where supervisors have to hunt for it.
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- You're writing a new module and need to decide function ordering
|
||||||
|
- A module has grown organically and functions are scattered randomly
|
||||||
|
- You're reviewing code and finding it hard to locate the public API
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
defmodule UserService do
|
||||||
|
defp hash_password(pw), do: # ...
|
||||||
|
|
||||||
|
def create(attrs) do
|
||||||
|
# uses hash_password
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
|
||||||
|
|
||||||
|
defp validate(attrs), do: # ...
|
||||||
|
|
||||||
|
def get(id), do: # ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
defmodule UserService do
|
||||||
|
# Lifecycle
|
||||||
|
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
|
||||||
|
|
||||||
|
# Public API
|
||||||
|
def create(attrs), do: # ...
|
||||||
|
def get(id), do: # ...
|
||||||
|
|
||||||
|
# Private helpers
|
||||||
|
defp validate(attrs), do: # ...
|
||||||
|
defp hash_password(pw), do: # ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- You have a tiny module (< 5 functions) where ordering doesn't matter much
|
||||||
|
- The module is a pure data module (just a struct + typespec)
|
||||||
|
- "Logical grouping" puts closely related public+private pairs together for readability
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# Forcing start_link to the top in a module that isn't an OTP process
|
||||||
|
defmodule MyApp.Parser do
|
||||||
|
# This module has no lifecycle — don't force OTP ordering
|
||||||
|
def start_link(_), do: raise "not a process" # Just to match the pattern?
|
||||||
|
def parse(input), do: # ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule MyApp.Parser do
|
||||||
|
@moduledoc "Parses input format X into structs"
|
||||||
|
|
||||||
|
def parse(input), do: # ...
|
||||||
|
def parse!(input), do: # ...
|
||||||
|
|
||||||
|
defp tokenize(input), do: # ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The ordering convention exists to make OTP-aware modules predictable. For non-OTP modules, lead with the primary public function (the one callers reach for first) and let the rest follow logically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `@moduledoc false` for Internal Modules
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/router/route.ex#L5](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/router/route.ex#L5)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# This module defines the Route struct that is used
|
||||||
|
# throughout Phoenix's router. This struct is private
|
||||||
|
# as it contains internal routing information.
|
||||||
|
@moduledoc false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Internal modules that exist for code organization but aren't part of the public API get `@moduledoc false`. They won't appear in generated documentation.
|
||||||
|
|
||||||
|
**Anti-pattern:** Documenting internal modules and confusing users about what's public API vs implementation detail.
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A module exists purely for internal code organization
|
||||||
|
- Users of your library should never call this module directly
|
||||||
|
- The module is a helper that could change or disappear between versions
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
defmodule MyApp.Repo.QueryBuilder do
|
||||||
|
@moduledoc """
|
||||||
|
Builds Ecto queries for the Repo module.
|
||||||
|
"""
|
||||||
|
# Now appears in docs, users try to call it directly
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
defmodule MyApp.Repo.QueryBuilder do
|
||||||
|
@moduledoc false
|
||||||
|
# Hidden from docs, clearly internal
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The module is part of your public API (even if rarely used)
|
||||||
|
- Users need to implement callbacks or extend the module
|
||||||
|
- The module defines a behaviour or protocol that others implement
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# Hiding a module that users actually need
|
||||||
|
defmodule MyApp.Errors do
|
||||||
|
@moduledoc false # But users need to pattern-match on these!
|
||||||
|
|
||||||
|
defmodule NotFound do
|
||||||
|
defexception [:message]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule MyApp.Errors do
|
||||||
|
@moduledoc "Error types raised by MyApp operations."
|
||||||
|
|
||||||
|
defmodule NotFound do
|
||||||
|
@moduledoc "Raised when a resource cannot be found."
|
||||||
|
defexception [:message]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `@moduledoc false` means "this is not for you." If users catch your exceptions or match on your structs, they need documentation. Hide implementation details, not public contracts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Struct Definition Conventions
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/task.ex#L279](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/task.ex#L279)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
@enforce_keys [:mfa, :owner, :pid, :ref]
|
||||||
|
defstruct @enforce_keys
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
mfa: mfa(),
|
||||||
|
owner: pid(),
|
||||||
|
pid: pid() | nil,
|
||||||
|
ref: ref()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/router/route.ex#L30](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/router/route.ex#L30)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defstruct [
|
||||||
|
:verb,
|
||||||
|
:line,
|
||||||
|
:kind,
|
||||||
|
:path,
|
||||||
|
:hosts,
|
||||||
|
:plug,
|
||||||
|
:plug_opts,
|
||||||
|
:helper,
|
||||||
|
:private,
|
||||||
|
:pipe_through,
|
||||||
|
:assigns,
|
||||||
|
:metadata,
|
||||||
|
:trailing_slash?,
|
||||||
|
:warn_on_verify?
|
||||||
|
]
|
||||||
|
|
||||||
|
@type t :: %Route{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two patterns:
|
||||||
|
1. **Task:** `@enforce_keys` + minimal struct — all fields required, enforced at creation
|
||||||
|
2. **Route:** All optional fields listed — flexible construction
|
||||||
|
|
||||||
|
**Why:** Use `@enforce_keys` when a struct is meaningless without certain fields. Omit it for structs built incrementally.
|
||||||
|
|
||||||
|
**Anti-pattern:** Never using `@enforce_keys` — allows creating invalid structs that crash later when a required field is `nil`.
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Your struct has fields that make no sense as `nil` (creating one without them is a bug)
|
||||||
|
- You're modeling a value object where all fields define its identity
|
||||||
|
- Incomplete structs would cause confusing runtime errors later
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
defmodule Order do
|
||||||
|
defstruct [:id, :customer_id, :items, :total]
|
||||||
|
# Can create %Order{} with everything nil — meaningless
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
defmodule Order do
|
||||||
|
@enforce_keys [:customer_id, :items, :total]
|
||||||
|
defstruct [:id | @enforce_keys]
|
||||||
|
# %Order{} without required fields -> immediate compile/runtime error
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The struct is built incrementally (e.g., a changeset or builder pattern)
|
||||||
|
- Most fields have sensible defaults
|
||||||
|
- The struct represents configuration where partial specs are valid
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# Enforcing keys on a struct that's built in stages
|
||||||
|
defmodule FormState do
|
||||||
|
@enforce_keys [:step, :name, :email, :address, :payment]
|
||||||
|
defstruct @enforce_keys
|
||||||
|
# Can't create a partial form state for step 1!
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule FormState do
|
||||||
|
defstruct step: 1, name: nil, email: nil, address: nil, payment: nil
|
||||||
|
# Built incrementally as user progresses through steps
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `@enforce_keys` is for structs that represent *complete* values. If your struct represents an evolving state or has legitimate intermediate forms, enforcing all keys makes construction impossible at early stages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Selective Imports in `__using__`
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/channel.ex#L463](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/channel.ex#L463)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
import unquote(__MODULE__)
|
||||||
|
import Phoenix.Socket, only: [assign: 3, assign: 2]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/router.ex#L271](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/router.ex#L271)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
import Phoenix.Router
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The `use` macro sets up the module's environment — importing the functions you'll need. Phoenix.Channel imports `assign` from Socket because channels work with sockets constantly.
|
||||||
|
|
||||||
|
**Anti-pattern:** Importing everything without restriction — namespace pollution and hard-to-trace function origins.
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Your `use` macro needs to give the caller access to specific functions
|
||||||
|
- You want to control exactly which functions enter the caller's namespace
|
||||||
|
- The imported functions are central to the DSL or workflow the module enables
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
# Imports EVERYTHING from three modules — namespace soup
|
||||||
|
import MyApp.Router.Helpers
|
||||||
|
import MyApp.Router.Scoping
|
||||||
|
import MyApp.Router.Compilation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
import MyApp.Router, only: [get: 2, post: 2, resources: 2, scope: 2]
|
||||||
|
import MyApp.Conn, only: [assign: 3, put_status: 2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The caller could just `import` what they need themselves
|
||||||
|
- You're importing utility functions that aren't part of your module's "DSL"
|
||||||
|
- The imports create naming conflicts with common functions
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
import MyApp.Utils # 50+ utility functions dumped into caller
|
||||||
|
import Enum # Why? Caller can do this themselves
|
||||||
|
import Map # Polluting namespace with standard lib
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
# Only import what THIS module's workflow requires
|
||||||
|
import MyApp.DSL, only: [field: 2, validate: 1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `use` should import the *minimum* needed for the module's intended workflow. If you're importing generic utilities, you're making decisions for the caller that they should make themselves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Alias at Module Scope for Readability
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/router.ex#L268](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/router.ex#L268)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
alias Phoenix.Router.{Resource, Scope, Route, Helpers}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Multi-alias reduces repetition and groups related modules. The curly-brace syntax makes it clear these all share a parent namespace.
|
||||||
|
|
||||||
|
**Anti-pattern:** Using full module paths everywhere (`Phoenix.Router.Resource.new(...)`) — verbose and hard to read.
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Multiple modules from the same parent namespace are used together
|
||||||
|
- Full module paths are making code hard to read
|
||||||
|
- The aliased modules are used frequently (3+ times in the file)
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
def process(input) do
|
||||||
|
Phoenix.Router.Route.new(input)
|
||||||
|
|> Phoenix.Router.Scope.apply_scope(Phoenix.Router.Scope.current())
|
||||||
|
|> Phoenix.Router.Helpers.generate()
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
alias Phoenix.Router.{Route, Scope, Helpers}
|
||||||
|
|
||||||
|
def process(input) do
|
||||||
|
Route.new(input)
|
||||||
|
|> Scope.apply_scope(Scope.current())
|
||||||
|
|> Helpers.generate()
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- A module is referenced only once (inline the full path)
|
||||||
|
- The alias would be ambiguous (two `Route` modules from different namespaces)
|
||||||
|
- You're in a test file and the full path makes assertions clearer
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# Aliasing a module used exactly once
|
||||||
|
alias MyApp.Workers.BatchProcessor
|
||||||
|
|
||||||
|
def run do
|
||||||
|
BatchProcessor.start() # Only reference — alias adds noise
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
def run do
|
||||||
|
MyApp.Workers.BatchProcessor.start() # One use — full path is fine
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Aliases trade verbosity for indirection. When a module appears once, the full path is documentation. When it appears many times, the alias is readability. Find the crossover point (typically 2-3 uses).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Boolean-Suffixed Fields in Structs
|
||||||
|
|
||||||
|
**Source:** [lib/phoenix/router/route.ex#L43](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/phoenix/router/route.ex#L43)
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
:trailing_slash?,
|
||||||
|
:warn_on_verify?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The `?` suffix on struct fields mirrors the Elixir convention for boolean-returning functions. It makes the field's type obvious without checking the typespec.
|
||||||
|
|
||||||
|
**Anti-pattern:** Using bare names like `:trailing_slash` or `:has_trailing_slash` — the `?` convention is more idiomatic and self-documenting.
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A struct field stores a boolean value
|
||||||
|
- The field answers a yes/no question about the struct
|
||||||
|
- You want the field's type to be self-evident without checking typespecs
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
defstruct [:path, :trailing_slash, :verified]
|
||||||
|
# Is :trailing_slash the slash character? A boolean? The position?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
defstruct [:path, :trailing_slash?, :verified?]
|
||||||
|
# Immediately clear these are booleans
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The field isn't a boolean (e.g., `:status` that can be `:active`/`:inactive`)
|
||||||
|
- You're working with external serialization that can't handle `?` in keys
|
||||||
|
- The field represents a count, enum, or value rather than a yes/no question
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
defstruct [:user?, :admin?, :count?]
|
||||||
|
# :user? — is this "is user present?" or "the user value"?
|
||||||
|
# :count? — definitely not a boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defstruct [:user, :admin?, :count]
|
||||||
|
# :user is the user struct, :admin? is a boolean, :count is an integer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The `?` suffix should only mark genuine booleans. Using it on non-boolean fields creates confusion about the field's type and breaks the convention's usefulness as a type signal.
|
||||||
|
|
||||||
|
<!-- PATTERN_COMPLETE -->
|
||||||
File diff suppressed because it is too large
Load Diff
+1728
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,801 @@
|
|||||||
|
# Typespecs Patterns
|
||||||
|
|
||||||
|
Patterns extracted from the Elixir standard library source code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Public Type with @typedoc
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/gen_server.ex#L862](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L862)
|
||||||
|
|
||||||
|
**What it does:** Every public `@type` is preceded by a `@typedoc` that explains what the type represents, often referencing which functions use it.
|
||||||
|
|
||||||
|
**Why:** Types are part of the public API. Without documentation, users must guess what a type means from its definition alone. The `@typedoc` bridges intent and implementation.
|
||||||
|
|
||||||
|
**Anti-pattern:** Defining `@type` without any `@typedoc`, leaving users to decipher complex union types.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@typedoc "Return values of `start*` functions"
|
||||||
|
@type on_start :: {:ok, pid} | :ignore | {:error, {:already_started, pid} | term}
|
||||||
|
|
||||||
|
@typedoc "The GenServer name"
|
||||||
|
@type name :: nil | atom | {:global, term} | {:via, module, term}
|
||||||
|
|
||||||
|
@typedoc """
|
||||||
|
The server reference.
|
||||||
|
|
||||||
|
This is either a plain PID or a value representing a registered name.
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
@type server :: pid | name | {atom, node}
|
||||||
|
|
||||||
|
@typedoc """
|
||||||
|
Tuple describing the client of a call request.
|
||||||
|
|
||||||
|
`pid` is the PID of the caller and `tag` is a unique term used to identify the
|
||||||
|
call.
|
||||||
|
"""
|
||||||
|
@type from :: {pid, tag :: term}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- You define a public `@type` that appears in any `@spec` or callback signature
|
||||||
|
- The type has more than one clause or is a tagged tuple
|
||||||
|
- Other modules will reference this type
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@type status :: :pending | :active | :suspended | :terminated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@typedoc """
|
||||||
|
Account lifecycle status.
|
||||||
|
|
||||||
|
Used by `activate/1`, `suspend/1`, and `terminate/1` to indicate
|
||||||
|
the current state of a user account.
|
||||||
|
"""
|
||||||
|
@type status :: :pending | :active | :suspended | :terminated
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The type is private (`@typep`) and only used internally within the module
|
||||||
|
- The type name is completely self-explanatory and has a single, obvious clause (e.g., `@type id :: pos_integer()`)
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
@typedoc "A boolean value"
|
||||||
|
@type enabled :: boolean()
|
||||||
|
|
||||||
|
@typedoc "The name"
|
||||||
|
@type name :: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
# Self-explanatory single-clause types don't need @typedoc
|
||||||
|
@type enabled :: boolean()
|
||||||
|
@type name :: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Adding a `@typedoc` that merely restates the type name and definition adds noise without information. Reserve `@typedoc` for types where the name alone doesn't convey the full meaning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Private Types with @typep
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/macro.ex#L84](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/macro.ex#L84), 97
|
||||||
|
|
||||||
|
**What it does:** Uses `@typep` for internal recursive type definitions that are implementation details not meant for external consumers.
|
||||||
|
|
||||||
|
**Why:** Keeps the public type surface small while allowing internal type reuse. Private types can reference themselves recursively (e.g., for AST node structure) without polluting the documentation.
|
||||||
|
|
||||||
|
**Anti-pattern:** Making all types public "just in case." This bloats documentation and creates implicit public API guarantees.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@typedoc "The inputs of a macro"
|
||||||
|
@type input ::
|
||||||
|
input_expr
|
||||||
|
| {input, input}
|
||||||
|
| [input]
|
||||||
|
| atom
|
||||||
|
| number
|
||||||
|
| binary
|
||||||
|
|
||||||
|
@typep input_expr :: {input_expr | atom, metadata, atom | [input]}
|
||||||
|
|
||||||
|
@typedoc "The output of a macro"
|
||||||
|
@type output ::
|
||||||
|
output_expr
|
||||||
|
| {output, output}
|
||||||
|
| [output]
|
||||||
|
| atom
|
||||||
|
| number
|
||||||
|
| binary
|
||||||
|
| captured_remote_function
|
||||||
|
| pid
|
||||||
|
|
||||||
|
@typep output_expr :: {output_expr | atom, metadata, atom | [output]}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A type is used only within the defining module (helper type for recursion, intermediate structure)
|
||||||
|
- You want to DRY up repeated type expressions inside the module without exposing them
|
||||||
|
- The type represents an internal data structure that could change without notice
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
# Repeated inline in multiple specs
|
||||||
|
@spec parse(String.t()) :: {atom | {atom, list, list}, map(), atom | list}
|
||||||
|
@spec transform({atom | {atom, list, list}, map(), atom | list}) :: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@typep node :: {atom | {atom, list, node}, map(), atom | [node]}
|
||||||
|
|
||||||
|
@spec parse(String.t()) :: node()
|
||||||
|
@spec transform(node()) :: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- Other modules need to reference the type in their own specs
|
||||||
|
- The type is part of a public struct or protocol contract
|
||||||
|
- You want Dialyzer to catch misuse across module boundaries (opaque types are better for this)
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
defmodule Parser do
|
||||||
|
@typep ast :: {atom, keyword(), [ast]}
|
||||||
|
|
||||||
|
# But then in another module...
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Transformer do
|
||||||
|
# Can't reference Parser.ast() — it's private!
|
||||||
|
@spec transform(term()) :: term() # forced to use term()
|
||||||
|
def transform(ast), do: ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule Parser do
|
||||||
|
@type ast :: {atom, keyword(), [ast]}
|
||||||
|
# OR use @opaque if you want to hide the structure but allow cross-module reference
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Transformer do
|
||||||
|
@spec transform(Parser.ast()) :: Parser.ast()
|
||||||
|
def transform(ast), do: ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `@typep` hides the type from other modules entirely. If cross-module usage exists, you need `@type` (public) or `@opaque` (hidden structure, public name).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. @opaque Types (Protocol t())
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/protocol.ex#L150](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/protocol.ex#L150) (documentation), runtime generation
|
||||||
|
|
||||||
|
**What it does:** Protocols auto-generate an opaque `@type t :: term()` type that represents "any value implementing this protocol."
|
||||||
|
|
||||||
|
**Why:** The type is opaque because the concrete implementations are open-ended — any module can implement the protocol. Making it opaque prevents users from pattern-matching on the internal representation (which could be anything) and signals that you should only use the protocol functions.
|
||||||
|
|
||||||
|
**Anti-pattern:** Defining `@opaque` for types where consumers *need* to destructure the value. Use `@opaque` only when the internal representation is truly hidden.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
# Generated automatically by defprotocol — documented behavior:
|
||||||
|
defprotocol Size do
|
||||||
|
@doc "Calculates the size (and not the length!) of a data structure"
|
||||||
|
def size(data)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Usage in specs:
|
||||||
|
@spec print_size(Size.t()) :: :ok
|
||||||
|
def print_size(data) do
|
||||||
|
# ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- You build a data structure whose internals should never be accessed directly (e.g., wrapper around ETS, NIF resource, or complex nested map)
|
||||||
|
- You provide a complete set of accessor/manipulation functions and want to enforce using them
|
||||||
|
- You define a protocol where the implementing type is unknowable at definition time
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@type t :: %__MODULE__{items: list(), size: non_neg_integer()}
|
||||||
|
# Users can (and will) do: queue.items |> Enum.reverse()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@opaque t :: %__MODULE__{items: list(), size: non_neg_integer()}
|
||||||
|
# Dialyzer warns if users access .items directly
|
||||||
|
# They must use Queue.push/2, Queue.pop/1, Queue.size/1
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- Users legitimately need to pattern-match or destructure the value (e.g., tagged tuples like `{:ok, value}`)
|
||||||
|
- The struct fields are part of the documented public API
|
||||||
|
- The type is simple enough that hiding it adds ceremony without protection
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
defmodule Config do
|
||||||
|
@opaque t :: %__MODULE__{timeout: pos_integer(), retries: non_neg_integer()}
|
||||||
|
defstruct [:timeout, :retries]
|
||||||
|
|
||||||
|
# Now users can't do: config.timeout — Dialyzer complains
|
||||||
|
# You're forced to write getters for every field
|
||||||
|
def timeout(%__MODULE__{timeout: t}), do: t
|
||||||
|
def retries(%__MODULE__{retries: r}), do: r
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule Config do
|
||||||
|
@type t :: %__MODULE__{timeout: pos_integer(), retries: non_neg_integer()}
|
||||||
|
defstruct [:timeout, :retries]
|
||||||
|
# Users access fields directly — it's just config, not a black box
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `@opaque` is a contract that says "you may not look inside." If users need direct field access and the struct layout is stable, a regular `@type` is the right choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Union Types in @spec Return Values
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/gen_server.ex#L577](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L577), 647–658
|
||||||
|
|
||||||
|
**What it does:** Uses union types with descriptive tagged tuples in callback specs, making all possible return shapes explicit.
|
||||||
|
|
||||||
|
**Why:** OTP callbacks accept multiple return shapes (e.g., `{:reply, ...}`, `{:noreply, ...}`, `{:stop, ...}`). Spelling out every variant in the spec enables Dialyzer and makes the contract self-documenting.
|
||||||
|
|
||||||
|
**Anti-pattern:** Using `term()` as a catch-all return type when specific shapes are known. This defeats the purpose of typespecs.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@callback init(init_arg :: term) ::
|
||||||
|
{:ok, state}
|
||||||
|
| {:ok, state, timeout | :hibernate | {:continue, continue_arg :: term}}
|
||||||
|
| :ignore
|
||||||
|
| {:stop, reason :: term}
|
||||||
|
when state: term
|
||||||
|
|
||||||
|
@callback handle_call(request :: term, from, state :: term) ::
|
||||||
|
{:reply, reply, new_state}
|
||||||
|
| {:reply, reply, new_state,
|
||||||
|
timeout | :hibernate | {:continue, continue_arg :: term}}
|
||||||
|
| {:noreply, new_state}
|
||||||
|
| {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg :: term}}
|
||||||
|
| {:stop, reason, reply, new_state}
|
||||||
|
| {:stop, reason, new_state}
|
||||||
|
when reply: term, new_state: term, reason: term
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A function can return multiple distinct shapes (tagged tuples, atoms, or different structures)
|
||||||
|
- You're defining a behaviour callback where implementers need to know all valid returns
|
||||||
|
- The function has error cases that should be visible in the type signature
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@spec fetch(key :: String.t()) :: term()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@spec fetch(key :: String.t()) ::
|
||||||
|
{:ok, value :: term()}
|
||||||
|
| {:error, :not_found}
|
||||||
|
| {:error, :expired, expired_at :: DateTime.t()}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The function truly returns an unbounded set of types (e.g., a deserializer that can return any Elixir term)
|
||||||
|
- You're listing so many variants that the spec becomes harder to read than the function body itself
|
||||||
|
- The union has more than ~6-8 clauses — consider defining a named `@type` instead
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
@spec process(input()) ::
|
||||||
|
{:ok, result()}
|
||||||
|
| {:error, :invalid_input}
|
||||||
|
| {:error, :timeout}
|
||||||
|
| {:error, :network_error}
|
||||||
|
| {:error, :parse_error}
|
||||||
|
| {:error, :rate_limited}
|
||||||
|
| {:error, :unauthorized}
|
||||||
|
| {:error, :not_found}
|
||||||
|
| {:error, :server_error}
|
||||||
|
| {:error, :unknown}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
@type error_reason ::
|
||||||
|
:invalid_input | :timeout | :network_error | :parse_error
|
||||||
|
| :rate_limited | :unauthorized | :not_found | :server_error | :unknown
|
||||||
|
|
||||||
|
@spec process(input()) :: {:ok, result()} | {:error, error_reason()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Extract large unions into named types. The spec stays readable, the error taxonomy is reusable, and you can add `@typedoc` to explain each reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `when` Constraints in Specs
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/kernel.ex#L635](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/kernel.ex#L635), 1072, 1455
|
||||||
|
|
||||||
|
**What it does:** Uses the `when` clause in `@spec` to bind type variables, expressing relationships between parameters and return values.
|
||||||
|
|
||||||
|
**Why:** When the same type variable appears in multiple positions, it communicates that those values are related (same type). This is critical for generic functions like `hd/1`, `max/2`.
|
||||||
|
|
||||||
|
**Anti-pattern:** Using unrelated type names in different argument positions when the same variable would express the contract more precisely.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@spec hd(nonempty_maybe_improper_list(elem, term)) :: elem when elem: term
|
||||||
|
|
||||||
|
@spec max(first, second) :: first | second when first: term, second: term
|
||||||
|
|
||||||
|
@spec tl(nonempty_maybe_improper_list(elem, last)) :: maybe_improper_list(elem, last) | last
|
||||||
|
when elem: term, last: term
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A function's return type depends on its input type (generic/polymorphic functions)
|
||||||
|
- The same value flows through unchanged and you want to express that
|
||||||
|
- Multiple parameters must share a type constraint
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@spec get_or_default(map(), atom(), term()) :: term()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@spec get_or_default(map(), atom(), default) :: term() | default when default: term()
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The relationship between input and output types is not actually enforced at runtime
|
||||||
|
- You have a single type variable that only appears once (it adds syntax without expressing a relationship)
|
||||||
|
- The constraint is always `when x: term` with no actual narrowing — it's just noise
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
@spec log(message) :: :ok when message: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
@spec log(String.t()) :: :ok
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** A `when` clause with a single variable that appears only once in the spec is equivalent to inlining the type. The `when` syntax only adds value when the same variable appears in multiple positions, expressing a type relationship.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Map Types with required/optional Keys
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/supervisor.ex#L602](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/supervisor.ex#L602), 644–652
|
||||||
|
|
||||||
|
**What it does:** Uses map type syntax with `required()` and `optional()` keys to define struct-like specs where some fields have defaults.
|
||||||
|
|
||||||
|
**Why:** Supervisors have a child_spec map where `:id` and `:start` are mandatory but `:restart`, `:shutdown` etc. are optional with defaults. The type system reflects this precisely.
|
||||||
|
|
||||||
|
**Anti-pattern:** Using a plain `map()` type or making all keys required when some have sensible defaults.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@type sup_flags() :: %{
|
||||||
|
strategy: strategy(),
|
||||||
|
intensity: non_neg_integer(),
|
||||||
|
period: pos_integer(),
|
||||||
|
auto_shutdown: auto_shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@type child_spec :: %{
|
||||||
|
required(:id) => atom() | term(),
|
||||||
|
required(:start) => {module(), function_name :: atom(), args :: [term()]},
|
||||||
|
optional(:restart) => restart(),
|
||||||
|
optional(:shutdown) => shutdown(),
|
||||||
|
optional(:type) => type(),
|
||||||
|
optional(:modules) => [module()] | :dynamic,
|
||||||
|
optional(:significant) => boolean()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- You accept a map (not a struct) with a mix of mandatory and optional keys
|
||||||
|
- The function has a "config" or "opts" map parameter with defaults for some keys
|
||||||
|
- You're documenting a protocol/behaviour where implementers return option maps
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@spec connect(map()) :: {:ok, pid()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@type connect_opts :: %{
|
||||||
|
required(:host) => String.t(),
|
||||||
|
required(:port) => pos_integer(),
|
||||||
|
optional(:timeout) => pos_integer(),
|
||||||
|
optional(:ssl) => boolean(),
|
||||||
|
optional(:pool_size) => pos_integer()
|
||||||
|
}
|
||||||
|
|
||||||
|
@spec connect(connect_opts()) :: {:ok, pid()}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- You're defining a struct — structs already enforce their fields via `defstruct` and `@enforce_keys`
|
||||||
|
- The map is truly open-ended (arbitrary keys) — use `%{optional(atom()) => term()}`
|
||||||
|
- A keyword list would be more idiomatic for the use case (most Elixir option APIs use keyword lists, not maps)
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# Defining required/optional for a struct
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
required(:name) => String.t(),
|
||||||
|
optional(:email) => String.t()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
# Structs use the standard struct type syntax
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
name: String.t(),
|
||||||
|
email: String.t() | nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Struct types in Elixir use `field: type` syntax, not `required/optional`. The `required()`/`optional()` syntax is for plain maps. Mixing them up confuses Dialyzer and readers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Keyword List Types for Options
|
||||||
|
|
||||||
|
**Source:** [lib/logger/lib/logger.ex#L509](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/logger/lib/logger.ex#L509)
|
||||||
|
|
||||||
|
**What it does:** Defines option types as keyword lists with specific key-value constraints, sometimes nested.
|
||||||
|
|
||||||
|
**Why:** Many Elixir APIs accept keyword options. Typing them precisely documents valid options and their expected value types without forcing users to read function documentation.
|
||||||
|
|
||||||
|
**Anti-pattern:** Typing options as `keyword()` (untyped keyword list), which provides zero guidance.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@type configure_opts :: [
|
||||||
|
level: level(),
|
||||||
|
translator_inspect_opts: Inspect.Opts.t(),
|
||||||
|
sync_threshold: non_neg_integer(),
|
||||||
|
discard_threshold: non_neg_integer(),
|
||||||
|
truncate: non_neg_integer() | :infinity,
|
||||||
|
utc_log: boolean()
|
||||||
|
]
|
||||||
|
|
||||||
|
@type formatter_opts :: [
|
||||||
|
colors: [
|
||||||
|
enabled: boolean(),
|
||||||
|
debug: atom(),
|
||||||
|
info: atom(),
|
||||||
|
warning: atom(),
|
||||||
|
error: atom()
|
||||||
|
],
|
||||||
|
format: String.t() | {module(), atom()},
|
||||||
|
metadata: :all | [atom()],
|
||||||
|
truncate: pos_integer() | :infinity,
|
||||||
|
utc_log: boolean()
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A function accepts a keyword list of options (the `opts \\ []` pattern)
|
||||||
|
- You have more than 2-3 options and want to document valid keys and their types
|
||||||
|
- The options type is reused across multiple functions in the module
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@spec start_link(keyword()) :: GenServer.on_start()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@type start_opts :: [
|
||||||
|
name: GenServer.name(),
|
||||||
|
timeout: pos_integer(),
|
||||||
|
debug: [:trace | :log | :statistics]
|
||||||
|
]
|
||||||
|
|
||||||
|
@spec start_link(start_opts()) :: GenServer.on_start()
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The function takes 1-2 simple options that are obvious from the function's `@doc`
|
||||||
|
- The options are passed through to another function unchanged (type the pass-through, not the intermediate)
|
||||||
|
- You're defining it but Dialyzer can't actually check keyword list shapes deeply — don't give false confidence
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
@type greet_opts :: [name: String.t()]
|
||||||
|
|
||||||
|
@spec greet(greet_opts()) :: String.t()
|
||||||
|
def greet(opts \\ []) do
|
||||||
|
"Hello, #{opts[:name] || "world"}"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
@spec greet(name :: String.t()) :: String.t()
|
||||||
|
def greet(name \\ "world") do
|
||||||
|
"Hello, #{name}"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** A single optional value is better expressed as a default argument. Keyword list types shine when you have many options with different types. One option wrapped in a keyword list is over-engineering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Parameterized Types (t/1)
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/enum.ex#L58](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/enum.ex#L58) (Enumerable protocol)
|
||||||
|
|
||||||
|
**What it does:** Defines a parameterized type `t(_element)` that allows expressing the element type of an enumerable in function specs.
|
||||||
|
|
||||||
|
**Why:** Enables downstream functions to express type flow: "takes an enumerable of integers, returns an enumerable of strings." The parameter communicates element type even though the protocol dispatch erases it at runtime.
|
||||||
|
|
||||||
|
**Anti-pattern:** Always using `t()` without parameters when the element type is known and useful for documentation.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@typedoc """
|
||||||
|
An enumerable of elements of type `element`.
|
||||||
|
|
||||||
|
This type is equivalent to `t:t/0` but is especially useful for documentation.
|
||||||
|
|
||||||
|
For example, imagine you define a function that expects an enumerable of
|
||||||
|
integers and returns an enumerable of strings:
|
||||||
|
|
||||||
|
@spec integers_to_strings(Enumerable.t(integer())) :: Enumerable.t(String.t())
|
||||||
|
def integers_to_strings(integers) do
|
||||||
|
Stream.map(integers, &Integer.to_string/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
@typedoc since: "1.14.0"
|
||||||
|
@type t(_element) :: t()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- You define a container/collection type and want specs to express what's inside
|
||||||
|
- Functions transform the element type (e.g., map, filter, convert)
|
||||||
|
- Your library exports a generic data structure (queue, tree, ring buffer)
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@type queue :: %__MODULE__{items: list()}
|
||||||
|
|
||||||
|
@spec push(queue(), term()) :: queue()
|
||||||
|
@spec pop(queue()) :: {term(), queue()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@type t(element) :: %__MODULE__{items: [element]}
|
||||||
|
|
||||||
|
@spec push(t(element), element) :: t(element) when element: term()
|
||||||
|
@spec pop(t(element)) :: {element, t(element)} when element: term()
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The container always holds a fixed type (e.g., a `TokenBucket` that only holds integers — just use `integer()` directly)
|
||||||
|
- Dialyzer can't actually track the parameter through your implementation (it mostly can't for complex cases) — the value is documentation-only
|
||||||
|
- The parameterization would require more than one type variable and the spec becomes unreadable
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
@type config(value_type) :: %{key: atom(), value: value_type}
|
||||||
|
|
||||||
|
# But it's always used with String.t() in practice:
|
||||||
|
@spec get_config() :: config(String.t())
|
||||||
|
@spec set_config(config(String.t())) :: :ok
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
@type config :: %{key: atom(), value: String.t()}
|
||||||
|
|
||||||
|
@spec get_config() :: config()
|
||||||
|
@spec set_config(config()) :: :ok
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Parameterized types add value when the parameter actually varies across usage sites. If every call site uses the same concrete type, the parameter is abstraction without benefit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Named Parameters in Specs (:: annotation)
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/gen_server.ex#L577](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/gen_server.ex#L577), [lib/elixir/lib/supervisor.ex#L562](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/supervisor.ex#L562)
|
||||||
|
|
||||||
|
**What it does:** Uses `name :: type` syntax in callback/spec parameter positions to give meaningful names to parameters.
|
||||||
|
|
||||||
|
**Why:** `init(init_arg :: term)` is vastly more readable than `init(term)`. The name serves as inline documentation within the spec itself.
|
||||||
|
|
||||||
|
**Anti-pattern:** Writing specs with only types and no parameter names, especially for callbacks that users must implement.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@callback init(init_arg :: term) ::
|
||||||
|
{:ok, state}
|
||||||
|
| {:ok, state, timeout | :hibernate | {:continue, continue_arg :: term}}
|
||||||
|
| :ignore
|
||||||
|
| {:stop, reason :: term}
|
||||||
|
when state: term
|
||||||
|
|
||||||
|
@callback terminate(reason, state :: term) :: term
|
||||||
|
when reason: :normal | :shutdown | {:shutdown, term} | term
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- A parameter's type alone doesn't convey its purpose (e.g., `term()`, `String.t()`, `integer()`)
|
||||||
|
- You're defining a callback that other developers must implement
|
||||||
|
- The function has multiple parameters of the same type
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@spec transfer(String.t(), String.t(), pos_integer()) :: {:ok, reference()} | {:error, term()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@spec transfer(from_account :: String.t(), to_account :: String.t(), amount :: pos_integer()) ::
|
||||||
|
{:ok, reference()} | {:error, term()}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The type itself is descriptive enough (e.g., `pid()`, `module()`, `boolean()`)
|
||||||
|
- The function has a single parameter and the function name makes it obvious
|
||||||
|
- The name would just repeat the type (e.g., `string :: String.t()`)
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
@spec alive?(pid :: pid()) :: boolean()
|
||||||
|
@spec start(module :: module()) :: {:ok, pid()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
@spec alive?(pid()) :: boolean()
|
||||||
|
@spec start(module()) :: {:ok, pid()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `pid()` and `module()` are self-documenting types. Adding `pid :: pid()` is like writing `# increments x` above `x += 1`. The annotation should add information the type alone doesn't convey.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. @typedoc since: Annotation
|
||||||
|
|
||||||
|
**Source:** [lib/elixir/lib/supervisor.ex#L669](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/supervisor.ex#L669), [lib/elixir/lib/enum.ex#L72](https://github.com/elixir-lang/elixir/blob/f4e1b34617ef92052b65781f18eae5b88a490098/lib/elixir/lib/enum.ex#L72)
|
||||||
|
|
||||||
|
**What it does:** Attaches a `since:` metadata annotation to `@typedoc` indicating when a type was introduced.
|
||||||
|
|
||||||
|
**Why:** Helps library consumers know version requirements. The pattern mirrors `@doc since:` used on functions.
|
||||||
|
|
||||||
|
**Anti-pattern:** Omitting `since:` on types added after the initial release, leaving users guessing about compatibility.
|
||||||
|
|
||||||
|
**Code example:**
|
||||||
|
```elixir
|
||||||
|
@typedoc since: "1.16.0"
|
||||||
|
@type module_spec :: {module(), args :: term()} | module()
|
||||||
|
|
||||||
|
@typedoc since: "1.14.0"
|
||||||
|
@type t(_element) :: t()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- You're adding a new public type to an existing library (post-1.0 release)
|
||||||
|
- Your library follows semantic versioning and users need to know minimum version requirements
|
||||||
|
- You're maintaining a changelog and want types to be traceable to releases
|
||||||
|
|
||||||
|
**Example — before:**
|
||||||
|
```elixir
|
||||||
|
@typedoc "A validated email address"
|
||||||
|
@type email :: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — after:**
|
||||||
|
```elixir
|
||||||
|
@typedoc since: "2.3.0"
|
||||||
|
@typedoc "A validated email address"
|
||||||
|
@type email :: String.t()
|
||||||
|
```
|
||||||
|
|
||||||
|
### When NOT to Use
|
||||||
|
|
||||||
|
**Don't use this when:**
|
||||||
|
- The type has existed since the library's initial release (no version ambiguity)
|
||||||
|
- You're in an application (not a library) where version tracking of types is meaningless
|
||||||
|
- The library doesn't follow semver or publish versioned docs
|
||||||
|
|
||||||
|
**Over-application example:**
|
||||||
|
```elixir
|
||||||
|
# In a Phoenix app's context module
|
||||||
|
defmodule MyApp.Accounts do
|
||||||
|
@typedoc since: "0.1.0"
|
||||||
|
@type user_id :: pos_integer()
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better alternative:**
|
||||||
|
```elixir
|
||||||
|
defmodule MyApp.Accounts do
|
||||||
|
@type user_id :: pos_integer()
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `since:` annotations are for library consumers checking compatibility across versions. Application code doesn't have "consumers" checking which version introduced a type — it's all deployed together.
|
||||||
|
|
||||||
|
<!-- PATTERN_COMPLETE -->
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user