Installs into .claude/skills of the current project.
Are you the author of 1741 Spec Coverage 62dcd6d7?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/tools-only-1741-spec-coverage-62dcd6d7)
# Spec Coverage
## Common @spec Patterns
### Basic Types
```elixir
@spec greet(String.t()) :: String.t()
def greet(name), do: "Hello, #{name}!"
@spec count_items(list()) :: non_neg_integer()
def count_items(items), do: length(items)
@spec enabled?() :: boolean()
def enabled?, do: Application.get_env(:my_app, :enabled, false)
```
### Union Types
```elixir
@spec fetch_account(integer()) :: {:ok, Account.t()} | {:error, :not_found | :suspended}
def fetch_account(id) do
case Repo.get(Account, id) do
nil -> {:error, :not_found}
%Account{status: :suspended} = account -> {:error, :suspended}
account -> {:ok, account}
end
end
```
### Custom Types
```elixir
@type id :: pos_integer()
@type reason :: :not_found | :unauthorized | :timeout
@type result :: {:ok, t()} | {:error, reason()}
@spec find(id()) :: result()
def find(id), do: # ...
```
### Keyword Options
```elixir
@type option :: {:timeout, pos_integer()} | {:retries, non_neg_integer()}
@spec request(String.t(), [option()]) :: {:ok, Response.t()} | {:error, term()}
def request(url, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5_000)
retries = Keyword.get(opts, :retries, 3)
# ...
end
```
### When Clauses
```elixir
@spec transform(list(a), (a -> b)) :: list(b) when a: term(), b: term()
def transform(items, func), do: Enum.map(items, func)
@spec wrap(value) :: [value] when value: term()
def wrap(value), do: [value]
```
## @type and @typedoc
Use custom types to name domain concepts and reduce repetition.
```elixir
defmodule MyApp.Shipping do
@typedoc "Weight in grams."
@type weight :: non_neg_integer()
@typedoc "A geographic coordinate pair."
@type coordinates :: {latitude :: float(), longitude :: float()}
@typedoc "Shipping status throughout the delivery lifecycle."
@type status :: :pending | :in_transit | :delivered | :returned
@spec estimate_cost(weight(), coordinates(), coordinates()) :: {:ok, Decimal.t()}
def estimate_cost(weight, origin, destination) do
# ...
end
end
```
Benefits:
- `weight()` communicates intent better than `non_neg_integer()`
- `status()` centralizes valid values -- add a new status in one place
- `@typedoc` appears in ExDoc, making types self-documenting
## When @spec Is Required
All public exported functions should have @spec. This includes:
```elixir
defmodule MyApp.Accounts do
# Required: public function
@spec create_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def create_user(attrs), do: # ...
# Required: public function with default args
@spec list_users(keyword()) :: [User.t()]
def list_users(opts \\ []), do: # ...
# Required: public function used as callback
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts), do: # ...
end
```
## When @spec Is Optional
```elixir
defmodule MyApp.Accounts do
# Optional: private function
defp normalize_email(email), do: String.downcase(email)
# Optional: macro-generated functions (e.g., Ecto schema fields)
# These are generated by `schema` and `field` macros
# Optional: @impl true callbacks where the behaviour defines the spec
@impl true
def handle_call(:ping, _from, state), do: {:reply, :pong, state}
end
```
## Common @spec Mistakes
### Overly Broad term() or any()
```elixir
# BAD - term() hides what the function actually accepts
@spec process(term()) :: term()
def process(%Order{} = order), do: # ...
# GOOD - spec reflects the actual types
@spec process(Order.t()) :: {:ok, Receipt.t()} | {:error, String.t()}
def process(%Order{} = order), do: # ...
```
Using `term()` or `any()` defeats the purpose of specs. If you know the type, declare it.
### Missing Union Branches
```elixir
# BAD - forgets the nil case from Repo.get
@spec find_user(integer()) :: {:ok, User.t()}
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found} # Not reflected in spec!
user -> {:ok, user}
end
end
# GOOD - all return paths represented
@spec find_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
```
### Not Using Custom Types for Repeated Patterns
```elixir
# BAD - same tuple pattern repeated across many functions
@spec create(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec update(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec delete(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
# GOOD - define a type once, reuse it
@type changeset_result :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec create(map()) :: changeset_result()
@spec update(User.t(), map()) :: changeset_result()
@spec delete(User.t()) :: changeset_result()
```
### Specs That Don't Match Function Clauses
```elixir
# BAD - spec says it only accepts String.t() but function also handles nil
@spec normalize(String.t()) :: String.t()
def normalize(nil), do: ""
def normalize(value), do: String.trim(value)
# GOOD - spec covers all clauses
@spec normalize(String.t() | nil) :: String.t()
def normalize(nil), do: ""
def normalize(value), do: String.trim(value)
```
### Using list() When a Specific Element Type Is Known
```elixir
# BAD - list() tells the caller nothing about contents
@spec active_users() :: list()
def active_users, do: Repo.all(from u in User, where: u.active == true)
# GOOD - caller knows what's in the list
@spec active_users() :: [User.t()]
def active_users, do: Repo.all(from u in User, where: u.active == true)
```
## Dialyzer-Friendly Specs
Dialyzer uses @spec to perform success typing analysis. Specs that help Dialyzer catch real bugs:
```elixir
# Dialyzer can catch callers passing wrong types
@spec send_notification(User.t(), String.t()) :: :ok | {:error, :delivery_failed}
def send_notification(%User{email: email}, message) do
# ...
end
# Dialyzer can verify pattern match exhaustiveness
@type role :: :admin | :editor | :viewer
@spec permissions(role()) :: [atom()]
def permissions(:admin), do: [:read, :write, :delete]
def permissions(:editor), do: [:read, :write]
def permissions(:viewer), do: [:read]
# Dialyzer warns if a new role is added to the type but not handled here
```
Tips for Dialyzer compatibility:
- Avoid `@spec function() :: no_return()` unless the function truly never returns (e.g., raises always)
- Use `String.t()` instead of `binary()` for text data -- they're equivalent to Dialyzer but `String.t()` communicates intent
- Declare `@opaque` types when internal representation should not leak to callers
## Review Questions
1. Do all public functions have @spec?
2. Do specs accurately reflect all return paths (including error tuples)?
3. Are custom @type definitions used for repeated patterns?
4. Are specs specific enough to catch real bugs (no unnecessary term() or any())?