docs: add when/when-not to typespecs + documentation + behaviours
This commit is contained in:
@@ -39,6 +39,54 @@ call.
|
||||
@type from :: {pid, tag :: term}
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You define a public `@type` that appears in any `@spec` or callback signature
|
||||
- The type has more than one clause or is a tagged tuple
|
||||
- Other modules will reference this type
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@type status :: :pending | :active | :suspended | :terminated
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@typedoc """
|
||||
Account lifecycle status.
|
||||
|
||||
Used by `activate/1`, `suspend/1`, and `terminate/1` to indicate
|
||||
the current state of a user account.
|
||||
"""
|
||||
@type status :: :pending | :active | :suspended | :terminated
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The type is private (`@typep`) and only used internally within the module
|
||||
- The type name is completely self-explanatory and has a single, obvious clause (e.g., `@type id :: pos_integer()`)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@typedoc "A boolean value"
|
||||
@type enabled :: boolean()
|
||||
|
||||
@typedoc "The name"
|
||||
@type name :: String.t()
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
# Self-explanatory single-clause types don't need @typedoc
|
||||
@type enabled :: boolean()
|
||||
@type name :: String.t()
|
||||
```
|
||||
|
||||
**Why:** Adding a `@typedoc` that merely restates the type name and definition adds noise without information. Reserve `@typedoc` for types where the name alone doesn't convey the full meaning.
|
||||
|
||||
---
|
||||
|
||||
## 2. Private Types with @typep
|
||||
@@ -78,6 +126,66 @@ call.
|
||||
@typep output_expr :: {output_expr | atom, metadata, atom | [output]}
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- A type is used only within the defining module (helper type for recursion, intermediate structure)
|
||||
- You want to DRY up repeated type expressions inside the module without exposing them
|
||||
- The type represents an internal data structure that could change without notice
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
# Repeated inline in multiple specs
|
||||
@spec parse(String.t()) :: {atom | {atom, list, list}, map(), atom | list}
|
||||
@spec transform({atom | {atom, list, list}, map(), atom | list}) :: String.t()
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@typep node :: {atom | {atom, list, node}, map(), atom | [node]}
|
||||
|
||||
@spec parse(String.t()) :: node()
|
||||
@spec transform(node()) :: String.t()
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- Other modules need to reference the type in their own specs
|
||||
- The type is part of a public struct or protocol contract
|
||||
- You want Dialyzer to catch misuse across module boundaries (opaque types are better for this)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule Parser do
|
||||
@typep ast :: {atom, keyword(), [ast]}
|
||||
|
||||
# But then in another module...
|
||||
end
|
||||
|
||||
defmodule Transformer do
|
||||
# Can't reference Parser.ast() — it's private!
|
||||
@spec transform(term()) :: term() # forced to use term()
|
||||
def transform(ast), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule Parser do
|
||||
@type ast :: {atom, keyword(), [ast]}
|
||||
# OR use @opaque if you want to hide the structure but allow cross-module reference
|
||||
end
|
||||
|
||||
defmodule Transformer do
|
||||
@spec transform(Parser.ast()) :: Parser.ast()
|
||||
def transform(ast), do: ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `@typep` hides the type from other modules entirely. If cross-module usage exists, you need `@type` (public) or `@opaque` (hidden structure, public name).
|
||||
|
||||
---
|
||||
|
||||
## 3. @opaque Types (Protocol t())
|
||||
@@ -105,6 +213,58 @@ def print_size(data) do
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You build a data structure whose internals should never be accessed directly (e.g., wrapper around ETS, NIF resource, or complex nested map)
|
||||
- You provide a complete set of accessor/manipulation functions and want to enforce using them
|
||||
- You define a protocol where the implementing type is unknowable at definition time
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@type t :: %__MODULE__{items: list(), size: non_neg_integer()}
|
||||
# Users can (and will) do: queue.items |> Enum.reverse()
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@opaque t :: %__MODULE__{items: list(), size: non_neg_integer()}
|
||||
# Dialyzer warns if users access .items directly
|
||||
# They must use Queue.push/2, Queue.pop/1, Queue.size/1
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- Users legitimately need to pattern-match or destructure the value (e.g., tagged tuples like `{:ok, value}`)
|
||||
- The struct fields are part of the documented public API
|
||||
- The type is simple enough that hiding it adds ceremony without protection
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
defmodule Config do
|
||||
@opaque t :: %__MODULE__{timeout: pos_integer(), retries: non_neg_integer()}
|
||||
defstruct [:timeout, :retries]
|
||||
|
||||
# Now users can't do: config.timeout — Dialyzer complains
|
||||
# You're forced to write getters for every field
|
||||
def timeout(%__MODULE__{timeout: t}), do: t
|
||||
def retries(%__MODULE__{retries: r}), do: r
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule Config do
|
||||
@type t :: %__MODULE__{timeout: pos_integer(), retries: non_neg_integer()}
|
||||
defstruct [:timeout, :retries]
|
||||
# Users access fields directly — it's just config, not a black box
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `@opaque` is a contract that says "you may not look inside." If users need direct field access and the struct layout is stable, a regular `@type` is the right choice.
|
||||
|
||||
---
|
||||
|
||||
## 4. Union Types in @spec Return Values
|
||||
@@ -137,6 +297,60 @@ end
|
||||
when reply: term, new_state: term, reason: term
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- A function can return multiple distinct shapes (tagged tuples, atoms, or different structures)
|
||||
- You're defining a behaviour callback where implementers need to know all valid returns
|
||||
- The function has error cases that should be visible in the type signature
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@spec fetch(key :: String.t()) :: term()
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@spec fetch(key :: String.t()) ::
|
||||
{:ok, value :: term()}
|
||||
| {:error, :not_found}
|
||||
| {:error, :expired, expired_at :: DateTime.t()}
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The function truly returns an unbounded set of types (e.g., a deserializer that can return any Elixir term)
|
||||
- You're listing so many variants that the spec becomes harder to read than the function body itself
|
||||
- The union has more than ~6-8 clauses — consider defining a named `@type` instead
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@spec process(input()) ::
|
||||
{:ok, result()}
|
||||
| {:error, :invalid_input}
|
||||
| {:error, :timeout}
|
||||
| {:error, :network_error}
|
||||
| {:error, :parse_error}
|
||||
| {:error, :rate_limited}
|
||||
| {:error, :unauthorized}
|
||||
| {:error, :not_found}
|
||||
| {:error, :server_error}
|
||||
| {:error, :unknown}
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@type error_reason ::
|
||||
:invalid_input | :timeout | :network_error | :parse_error
|
||||
| :rate_limited | :unauthorized | :not_found | :server_error | :unknown
|
||||
|
||||
@spec process(input()) :: {:ok, result()} | {:error, error_reason()}
|
||||
```
|
||||
|
||||
**Why:** Extract large unions into named types. The spec stays readable, the error taxonomy is reusable, and you can add `@typedoc` to explain each reason.
|
||||
|
||||
---
|
||||
|
||||
## 5. `when` Constraints in Specs
|
||||
@@ -159,6 +373,43 @@ end
|
||||
when elem: term, last: term
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- A function's return type depends on its input type (generic/polymorphic functions)
|
||||
- The same value flows through unchanged and you want to express that
|
||||
- Multiple parameters must share a type constraint
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@spec get_or_default(map(), atom(), term()) :: term()
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@spec get_or_default(map(), atom(), default) :: term() | default when default: term()
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The relationship between input and output types is not actually enforced at runtime
|
||||
- You have a single type variable that only appears once (it adds syntax without expressing a relationship)
|
||||
- The constraint is always `when x: term` with no actual narrowing — it's just noise
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@spec log(message) :: :ok when message: String.t()
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@spec log(String.t()) :: :ok
|
||||
```
|
||||
|
||||
**Why:** A `when` clause with a single variable that appears only once in the spec is equivalent to inlining the type. The `when` syntax only adds value when the same variable appears in multiple positions, expressing a type relationship.
|
||||
|
||||
---
|
||||
|
||||
## 6. Map Types with required/optional Keys
|
||||
@@ -191,6 +442,59 @@ end
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You accept a map (not a struct) with a mix of mandatory and optional keys
|
||||
- The function has a "config" or "opts" map parameter with defaults for some keys
|
||||
- You're documenting a protocol/behaviour where implementers return option maps
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@spec connect(map()) :: {:ok, pid()}
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@type connect_opts :: %{
|
||||
required(:host) => String.t(),
|
||||
required(:port) => pos_integer(),
|
||||
optional(:timeout) => pos_integer(),
|
||||
optional(:ssl) => boolean(),
|
||||
optional(:pool_size) => pos_integer()
|
||||
}
|
||||
|
||||
@spec connect(connect_opts()) :: {:ok, pid()}
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- You're defining a struct — structs already enforce their fields via `defstruct` and `@enforce_keys`
|
||||
- The map is truly open-ended (arbitrary keys) — use `%{optional(atom()) => term()}`
|
||||
- A keyword list would be more idiomatic for the use case (most Elixir option APIs use keyword lists, not maps)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
# Defining required/optional for a struct
|
||||
@type t :: %__MODULE__{
|
||||
required(:name) => String.t(),
|
||||
optional(:email) => String.t()
|
||||
}
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
# Structs use the standard struct type syntax
|
||||
@type t :: %__MODULE__{
|
||||
name: String.t(),
|
||||
email: String.t() | nil
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Struct types in Elixir use `field: type` syntax, not `required/optional`. The `required()`/`optional()` syntax is for plain maps. Mixing them up confuses Dialyzer and readers.
|
||||
|
||||
---
|
||||
|
||||
## 7. Keyword List Types for Options
|
||||
@@ -229,6 +533,57 @@ end
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- A function accepts a keyword list of options (the `opts \\ []` pattern)
|
||||
- You have more than 2-3 options and want to document valid keys and their types
|
||||
- The options type is reused across multiple functions in the module
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@type start_opts :: [
|
||||
name: GenServer.name(),
|
||||
timeout: pos_integer(),
|
||||
debug: [:trace | :log | :statistics]
|
||||
]
|
||||
|
||||
@spec start_link(start_opts()) :: GenServer.on_start()
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The function takes 1-2 simple options that are obvious from the function's `@doc`
|
||||
- The options are passed through to another function unchanged (type the pass-through, not the intermediate)
|
||||
- You're defining it but Dialyzer can't actually check keyword list shapes deeply — don't give false confidence
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@type greet_opts :: [name: String.t()]
|
||||
|
||||
@spec greet(greet_opts()) :: String.t()
|
||||
def greet(opts \\ []) do
|
||||
"Hello, #{opts[:name] || "world"}"
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@spec greet(name :: String.t()) :: String.t()
|
||||
def greet(name \\ "world") do
|
||||
"Hello, #{name}"
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** A single optional value is better expressed as a default argument. Keyword list types shine when you have many options with different types. One option wrapped in a keyword list is over-engineering.
|
||||
|
||||
---
|
||||
|
||||
## 8. Parameterized Types (t/1)
|
||||
@@ -261,6 +616,56 @@ integers and returns an enumerable of strings:
|
||||
@type t(_element) :: t()
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You define a container/collection type and want specs to express what's inside
|
||||
- Functions transform the element type (e.g., map, filter, convert)
|
||||
- Your library exports a generic data structure (queue, tree, ring buffer)
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@type queue :: %__MODULE__{items: list()}
|
||||
|
||||
@spec push(queue(), term()) :: queue()
|
||||
@spec pop(queue()) :: {term(), queue()}
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@type t(element) :: %__MODULE__{items: [element]}
|
||||
|
||||
@spec push(t(element), element) :: t(element) when element: term()
|
||||
@spec pop(t(element)) :: {element, t(element)} when element: term()
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The container always holds a fixed type (e.g., a `TokenBucket` that only holds integers — just use `integer()` directly)
|
||||
- Dialyzer can't actually track the parameter through your implementation (it mostly can't for complex cases) — the value is documentation-only
|
||||
- The parameterization would require more than one type variable and the spec becomes unreadable
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@type config(value_type) :: %{key: atom(), value: value_type}
|
||||
|
||||
# But it's always used with String.t() in practice:
|
||||
@spec get_config() :: config(String.t())
|
||||
@spec set_config(config(String.t())) :: :ok
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@type config :: %{key: atom(), value: String.t()}
|
||||
|
||||
@spec get_config() :: config()
|
||||
@spec set_config(config()) :: :ok
|
||||
```
|
||||
|
||||
**Why:** Parameterized types add value when the parameter actually varies across usage sites. If every call site uses the same concrete type, the parameter is abstraction without benefit.
|
||||
|
||||
---
|
||||
|
||||
## 9. Named Parameters in Specs (:: annotation)
|
||||
@@ -286,6 +691,46 @@ integers and returns an enumerable of strings:
|
||||
when reason: :normal | :shutdown | {:shutdown, term} | term
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- A parameter's type alone doesn't convey its purpose (e.g., `term()`, `String.t()`, `integer()`)
|
||||
- You're defining a callback that other developers must implement
|
||||
- The function has multiple parameters of the same type
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@spec transfer(String.t(), String.t(), pos_integer()) :: {:ok, reference()} | {:error, term()}
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@spec transfer(from_account :: String.t(), to_account :: String.t(), amount :: pos_integer()) ::
|
||||
{:ok, reference()} | {:error, term()}
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The type itself is descriptive enough (e.g., `pid()`, `module()`, `boolean()`)
|
||||
- The function has a single parameter and the function name makes it obvious
|
||||
- The name would just repeat the type (e.g., `string :: String.t()`)
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
@spec alive?(pid :: pid()) :: boolean()
|
||||
@spec start(module :: module()) :: {:ok, pid()}
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
@spec alive?(pid()) :: boolean()
|
||||
@spec start(module()) :: {:ok, pid()}
|
||||
```
|
||||
|
||||
**Why:** `pid()` and `module()` are self-documenting types. Adding `pid :: pid()` is like writing `# increments x` above `x += 1`. The annotation should add information the type alone doesn't convey.
|
||||
|
||||
---
|
||||
|
||||
## 10. @typedoc since: Annotation
|
||||
@@ -306,3 +751,49 @@ integers and returns an enumerable of strings:
|
||||
@typedoc since: "1.14.0"
|
||||
@type t(_element) :: t()
|
||||
```
|
||||
|
||||
|
||||
### When to Use
|
||||
|
||||
**Triggers:**
|
||||
- You're adding a new public type to an existing library (post-1.0 release)
|
||||
- Your library follows semantic versioning and users need to know minimum version requirements
|
||||
- You're maintaining a changelog and want types to be traceable to releases
|
||||
|
||||
**Example — before:**
|
||||
```elixir
|
||||
@typedoc "A validated email address"
|
||||
@type email :: String.t()
|
||||
```
|
||||
|
||||
**Example — after:**
|
||||
```elixir
|
||||
@typedoc since: "2.3.0"
|
||||
@typedoc "A validated email address"
|
||||
@type email :: String.t()
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
**Don't use this when:**
|
||||
- The type has existed since the library's initial release (no version ambiguity)
|
||||
- You're in an application (not a library) where version tracking of types is meaningless
|
||||
- The library doesn't follow semver or publish versioned docs
|
||||
|
||||
**Over-application example:**
|
||||
```elixir
|
||||
# In a Phoenix app's context module
|
||||
defmodule MyApp.Accounts do
|
||||
@typedoc since: "0.1.0"
|
||||
@type user_id :: pos_integer()
|
||||
end
|
||||
```
|
||||
|
||||
**Better alternative:**
|
||||
```elixir
|
||||
defmodule MyApp.Accounts do
|
||||
@type user_id :: pos_integer()
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** `since:` annotations are for library consumers checking compatibility across versions. Application code doesn't have "consumers" checking which version introduced a type — it's all deployed together.
|
||||
|
||||
Reference in New Issue
Block a user