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

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