Skip to content
Development
Skill

/elixir-essentials

Use when writing, refactoring or reviewing any Elixir code, even a small function or a quick question about idioms — replacing nested if/case with pattern matching and guards, chaining {:ok, _}/{:error, _} steps with `with`, modern Elixir 1.18+ (type checker, JSON, dbg), Phoenix

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill elixir-essentials --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/elixir-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when writing, refactoring or reviewing any Elixir code, even a small function or a quick question about idioms — replacing nested if/case with pattern matching and guards, chaining {:ok, _}/{:error, _} steps with `with`, modern Elixir 1.18+ (type checker, JSON, dbg), Phoenix

SKILL.md

elixir-essentials.SKILL.md
name: elixir-essentials
description: "Use when writing, refactoring or reviewing any Elixir code, even a small function or a quick question about idioms — replacing nested if/case with pattern matching and guards, chaining {:ok, _}/{:error, _} steps with `with`, modern Elixir 1.18+ (type checker, JSON, dbg), Phoenix project layout. Not for OTP, Ecto, LiveView or tests (use the specific skill)."

Elixir Essentials

> Verified against Elixir 1.20, Phoenix 1.8, Ecto 3.14 — October 2026

Not for: OTP, Ecto, LiveView, tests — use the specific skill (`otp-essentials`, `ecto-essentials`, `phoenix-liveview-essentials`, `testing-essentials`).

Phoenix project layout: see [project-structure.md](project-structure.md).

RULES — Follow these with no exceptions

1. **Branch with multi-clause functions, pattern matching and `case`** — never nest `if`; `unless` is soft-deprecated, write `if !cond` 2. **A fallible function returns `{:ok, value} | {:error, reason}` on every path** — never a bare value from one clause and a tuple from another 3. **Error reasons are atoms (or a changeset), not strings** — translate them to messages at the edge (controller, LiveView) 4. **End a `with` on the last call itself, and no catch-all `else`** — `{:ok, x} <- f() do {:ok, x} end` is redundant (Credo `RedundantWithClauseResult`) 5. **Multi-step writes go inside `Repo.transact/2`** — a `with` alone does not roll back the first write when a later step fails 6. **Call `!` functions only when failure is a bug** — never wrap a bang call in `try/rescue`; call the non-bang version and `case` on it 7. **A struct pattern matches `nil` fields** — `%User{name: name}` binds `nil`; add `when is_binary(name)` when you rely on the value 8. **Treat compiler type warnings as bugs** — fix the code; don't add guards or `_ =` just to silence them 9. **Logging: put values you'll filter on in Logger metadata; a human-readable message may interpolate** — every module that logs has `require Logger` 10. **`@impl true` on every callback, clauses of one function grouped together** — split clauses fail `--warnings-as-errors`, and so does a callback without `@impl` once another callback has one

---

Return shapes

<!-- epg:bad -->

# Mixed shapes: callers can't pattern match on the result
def calculate(x) when is_integer(x) and x > 0, do: x * 2
def calculate(_), do: {:error, :invalid_input}
def calculate(x) when is_integer(x) and x > 0, do: {:ok, x * 2}
def calculate(_), do: {:error, :invalid_input}

@doc """
Fetches a post owned by the scope's user. Returns `{:ok, post}` or `{:error, :not_found}`.
"""
def fetch_post(%Scope{} = scope, id) do
  case Repo.get_by(Post, id: id, user_id: scope.user.id) do
    nil -> {:error, :not_found}
    post -> {:ok, post}
  end
end

If invalid input can only come from a bug, drop the fallback clause and let `FunctionClauseError` crash.

`with`

<!-- epg:bad -->

with {:ok, attrs} <- validate(params),
     {:ok, post} <- Blog.create_post(scope, attrs) do
  {:ok, post}
end
with {:ok, attrs} <- validate(params) do
  Blog.create_post(scope, attrs)
end

Writes that must succeed or fail together run in one transaction, return atom reasons, and let every error fall through unchanged:

def transfer(%Scope{} = scope, from_id, to_id, amount) do
  Repo.transact(fn ->
    with {:ok, from} <- fetch_own_account(scope, from_id),
         {:ok, to} <- fetch_account(to_id),
         :ok <- ensure_funds(from, amount),
         {:ok, _from} <- debit(from, amount),
         {:ok, _to} <- credit(to, amount) do
      {:ok, :transferred}
    end
  end)
end
# => {:ok, :transferred} | {:error, :not_found | :insufficient_funds | Ecto.Changeset.t()}

A catch-all `else error -> {:error, "failed: #{inspect(error)}"}` hides new error shapes and turns them into strings nobody can match on. Add an `else` only to translate specific reasons, and list each one. (Row locking for the balance check: see the `ecto-essentials` skill.)

Bang functions and exceptions

<!-- epg:bad -->

def parse(body) do
  try do
    {:ok, Jason.decode!(body)}
  rescue
    Jason.DecodeError -> {:error, :invalid_json}
  end
end
def parse(body) do
  case JSON.decode(body) do
    {:ok, %{"id" => id}} -> {:ok, id}
    {:ok, _other} -> {:error, :missing_id}
    {:error, _reason} -> {:error, :invalid_json}
  end
end
  • `Repo.get_by!/2` / `get_post!/2` are right when a missing record should 404: phoenix_ecto maps `Ecto.NoResultsError` to a 404 page. Use the non-bang version plus `case` when absence is a normal outcome.
  • Reserve `try/rescue` for code you don't control that only raises (no tuple-returning variant exists).

Pattern-matching traps

  • `%{}` matches **any** map, not an empty one; use `map_size(m) == 0`.
  • Params and decoded JSON have **string** keys: match `%{"id" => id}`. Never `String.to_atom/1` on input (atoms are never garbage collected); map strings to atoms explicitly or use `String.to_existing_atom/1`.
  • `map.key` raises `KeyError` when the key is missing; `map[:key]` returns `nil`. Structs don't implement `Access`, so use `struct.key`.
  • `==`, `<` and `>` on `DateTime`, `Date`, `Decimal` compare struct fields, not values. Use `DateTime.compare/2`, `Date.before?/2`, `Decimal.compare/2`.
def display_name(%User{name: name}) when is_binary(name), do: name
def display_name(%User{email: email}), do: email

Enum pipelines and `for`

A 2–3 step `Enum` pipeline is idiomatic. A `for` comprehension is an alternative when you want a single pass, filters, or `into:`/`reduce:`:

for item <- items, transformed = transform(item), valid?(transformed), do: format(transformed)

Logging

defmodule MyApp.Uploads do
  require Logger

  def process_upload(%Scope{} = scope, file) do
    case save_file(file) do
      {:ok, path} ->
        create_record(scope, path)

      {:error, reason} = error ->
        Logger.wa
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.