igmarin/elixir-phoenix-skills

typespec-dialyzer

Use when adding type safety to Elixir code, writing public functions, or refactoring. Specs document FCIS boundaries: pure core inputs/outputs and edge effects via tagged tuples. Covers @spec, @type, Dialyxir setup, ignore files, CI PLT cache. Trigger words: typespec, @spec, @type, Dialyzer, Dialyxir, type safety, type checking.

First seen Jun 20, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill typespec-dialyzer

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 7,052 B
  • docs SUMMARY.md 355 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 18 installs

SKILL.md

TypeSpec & Dialyzer

Types make data shapes and railway returns explicit — especially at module boundaries — so pure core and shell stay honest.

Canonical FP bar: [docs/fcis-engineering-rules.md](../../docs/fcis-engineering-rules.md).

Quick Reference

Concern Do this
Public APIs @spec at every public boundary
Structs @type t matches schema (incl. nilable timestamps)
Fallible ops `{:ok, t()} \ {:error, reason()}`
Dialyzer ignores ignore_warnings config, not fake CLI flags
CI Run Dialyzer; cache PLT on mix.lock

RULES — Follow these with no exceptions

1. @spec every public function — especially context/shell APIs and pure core entry points 2. @type t for structs/schemas — match real field types (NaiveDateTime.t() | nil for default timestamps) 3. Tagged-tuple success typing — {:ok, t()} | {:error, reason()} for fallible ops 4. Run Dialyzer in CI with PLT cache 5. Never ignore warnings silently — document in .dialyzerignore.exs via ignorewarnings config 6. Incremental adoption — core modules first 7. No any() as a habit — prefer unions and parameterized result types 8. Specs match reality — do not widen past success typing to silence Dialyzer

Specs at the FCIS boundary

❌ Bad: untyped public APIs

def get_user(id), do: Repo.get(User, id)
def create_user(attrs), do: %User{} |> User.changeset(attrs) |> Repo.insert()

✅ Good: explicit shapes and railway returns

defmodule MyApp.Orders.Pricing do
  @type line :: %{amount: non_neg_integer()}
  @type order :: %{lines: [line()]}

  @doc "Pure core — no side effects."
  @spec total(order()) :: non_neg_integer()
  def total(%{lines: lines}), do: Enum.reduce(lines, 0, &(&1.amount + &2))
end

defmodule MyApp.Orders do
  alias MyApp.Accounts.User
  alias MyApp.Repo

  @type id :: pos_integer()

  @spec get_user(id()) :: User.t() | nil
  def get_user(id), do: Repo.get(User, id)

  @spec create_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
  def create_user(attrs) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end
end

Struct types that match the schema

❌ Bad: types that lie about the schema

@type t :: %__MODULE__{
        role: :admin | :editor,          # field is :string
        inserted_at: DateTime.t()        # timestamps() default is naive + nilable
      }

✅ Good: align @type t with fields (or use Ecto.Enum + matching atom type)

defmodule MyApp.Accounts.User do
  use Ecto.Schema

  @type t :: %__MODULE__{
          id: integer() | nil,
          email: String.t(),
          username: String.t(),
          role: String.t() | nil,
          inserted_at: NaiveDateTime.t() | nil,
          updated_at: NaiveDateTime.t() | nil
        }

  # When using Ecto.Enum, prefer: @type role :: :admin | :editor | :viewer
  @type role :: String.t()

  @type attrs :: %{
          optional(:email) => String.t(),
          optional(:username) => String.t(),
          optional(:role) => role(),
          optional(:password) => String.t()
        }

  schema "users" do
    field :email, :string
    field :username, :string
    field :role, :string
    field :password, :string, virtual: true
    timestamps()
  end

  @spec changeset(t(), map()) :: Ecto.Changeset.t()
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :username, :role, :password])
    |> validate_required([:email, :username])
  end
end

Result types (railway)

@type result(ok, err) :: {:ok, ok} | {:error, err}

@spec divide(number(), number()) :: result(float(), :division_by_zero)
def divide(_n, 0), do: {:error, :division_by_zero}
def divide(n, d), do: {:ok, n / d}

@type status :: :active | :inactive | :suspended
@spec update_status(User.t(), status()) :: {:ok, User.t()} | {:error, atom()}

Opaque types for encapsulation

defmodule MyApp.Token do
  @opaque t :: %__MODULE__{value: String.t(), expires_at: DateTime.t()}
  defstruct [:value, :expires_at]

  @spec new(String.t(), DateTime.t()) :: t()
  def new(value, expires_at), do: %__MODULE__{value: value, expires_at: expires_at}
end

Dialyxir setup

# mix.exs deps
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
# config/config.exs (or config/dev.exs)
config :dialyxir,
  ignore_warnings: ".dialyzer_ignore.exs"
# .dialyzer_ignore.exs
[
  {"lib/my_app/legacy_module.ex", :unknown_type},
  ~r/unknown_function/
]

❌ Bad: non-existent CLI flag

mix dialyzer --ignore-file .dialyzer_ignore.exs

✅ Good: configure ignore_warnings and use supported formats

mix dialyzer                 # builds PLT on first run
mix dialyzer --format short
mix dialyzer --format ignore_file   # generate ignore entries

Reading errors

Error Meaning Fix
no_return Always raises/crashes Fix path or return type
call Arg does not match spec Fix caller or spec
contract_subtype Narrower return than spec Align spec/impl
unknown_type Missing @type / alias Define or import type
unmatched_return Caller ignores returns Pattern match all branches
mix dialyzer --format short
# fix → rerun until clean or documented ignore

CI + PLT cache

- name: Cache PLT
  uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
  with:
    path: priv/plts
    key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
    restore-keys: ${{ runner.os }}-mix-

- name: Dialyzer
  run: mix dialyzer --format short

Common pitfalls

❌ Don't ✅ Do
Spec map() everywhere at the core Parse to struct/typed attrs at the edge
DateTime.t() for default timestamps() `NaiveDateTime.t() \ nil` (or match your schema)
Atom role type with :string field String type or Ecto.Enum + atom type together
any() to silence Dialyzer Fix types or document ignore
Rebuild PLT every CI run Cache priv/plts on mix.lock
Untyped public context APIs @spec with `{:ok, _} \ {:error, _}`

Integration

Predecessor This Skill Successor
elixir-essentials typespec-dialyzer code-quality
ecto-essentials typespec-dialyzer testing-essentials

Companion: code-quality, credo-config.