From elixir
Guides idiomatic Elixir style: avoids bang functions in business logic, uses pattern matching for errors, designs Ecto schemas for validation.
npx claudepluginhub vinnie357/claude-skills --plugin elixirThis skill uses the workspace's default tool permissions.
Activate when:
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Activate when:
!) and non-bang function variantswith or casemap.key and map[:key] access patternsThis skill complements elixir:anti-patterns — that skill covers what to avoid; this one covers what to do instead.
Elixir functions signal success or failure through return values, not exceptions.
Standard return shapes:
# Two-element ok/error tuples (most common)
{:ok, result}
{:error, reason}
# Bare atoms for side-effect operations
:ok
:error
# Richer error tuples for typed failures
{:error, :not_found}
{:error, :unauthorized}
{:error, %Ecto.Changeset{}}
Why this convention matters:
Pattern matching on tagged tuples is the foundation of Elixir error handling. When every function in a call chain returns {:ok, _} or {:error, _}, with expressions can thread success values through and exit early on the first failure — without nested if or try/rescue.
# Callers can match exhaustively
case fetch_user(id) do
{:ok, user} -> render_profile(user)
{:error, :not_found} -> send_resp(conn, 404, "Not found")
{:error, reason} -> send_resp(conn, 500, inspect(reason))
end
Every standard library function with a ! variant follows this contract:
| Non-bang | Bang |
|---|---|
File.read/1 → {:ok, content} or {:error, reason} | File.read!/1 → content or raises |
Map.fetch/2 → {:ok, value} or :error | Map.fetch!/2 → value or raises KeyError |
Repo.insert/1 → {:ok, struct} or {:error, changeset} | Repo.insert!/1 → struct or raises |
Repo.get/2 → struct or nil | Repo.get!/2 → struct or raises Ecto.NoResultsError |
Use bang functions when failure represents a programming error or an invalid system state that should crash loudly:
# Application startup — missing config is a bug, not a user error
def start(_type, _args) do
api_key = Application.fetch_env!(:my_app, :stripe_api_key)
# ...
end
# Seeds and migrations — invalid data is a developer error
Repo.insert!(%User{email: "admin@example.com", role: :admin})
# Pipelines operating on already-validated, known-good data
"hello world"
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
# Tests asserting expected state
user = Repo.get!(User, user_id)
Avoid bang functions wherever failure is a normal, expected outcome:
# BAD: user input can always fail validation
def create_user(conn, %{"user" => params}) do
user = Repo.insert!(%User{email: params["email"]}) # raises on validation failure
json(conn, %{id: user.id})
end
# GOOD: handle the error path explicitly
def create_user(conn, %{"user" => params}) do
case %User{} |> User.changeset(params) |> Repo.insert() do
{:ok, user} -> json(conn, %{id: user.id})
{:error, changeset} -> conn |> put_status(422) |> json(changeset_errors(changeset))
end
end
# BAD: external APIs can return 404, 500, network errors
def fetch_payment(payment_id) do
Stripe.PaymentIntent.retrieve!(payment_id) # raises on API error
end
# GOOD: return a tagged tuple, let the caller decide
def fetch_payment(payment_id) do
case Stripe.PaymentIntent.retrieve(payment_id) do
{:ok, payment} -> {:ok, payment}
{:error, %{code: "resource_missing"}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
Rule of thumb: if a user action, external service, or DB constraint could cause the failure, use the non-bang variant and handle it.
with for Chaining Dependent OperationsUse with when multiple steps must all succeed, and any failure should short-circuit to an error response:
def register_user(params) do
with {:ok, validated} <- validate_registration_params(params),
{:ok, user} <- create_user(validated),
{:ok, _profile} <- create_default_profile(user),
{:ok, _email} <- send_welcome_email(user) do
{:ok, user}
else
{:error, %Ecto.Changeset{} = cs} -> {:error, {:validation_failed, cs}}
{:error, :email_unavailable} -> {:error, :email_taken}
{:error, reason} -> {:error, reason}
end
end
The else block is optional. Without it, unmatched patterns in with clauses propagate the first failing value as the return value of the entire expression.
Keep with flat. Nesting with inside with is a sign the function is doing too much:
# BAD: nested with — hard to follow
with {:ok, user} <- fetch_user(id) do
with {:ok, order} <- fetch_order(user, order_id) do
{:ok, {user, order}}
end
end
# GOOD: flat with
with {:ok, user} <- fetch_user(id),
{:ok, order} <- fetch_order(user, order_id) do
{:ok, {user, order}}
end
case for Single-Expression BranchingUse case when branching on one expression with multiple outcomes:
def handle_webhook(event_type, payload) do
case event_type do
"payment.succeeded" -> handle_payment_succeeded(payload)
"payment.failed" -> handle_payment_failed(payload)
"customer.created" -> handle_customer_created(payload)
unknown -> Logger.warning("Unhandled webhook: #{unknown}")
end
end
Use multi-clause functions for structural dispatch — matching on the shape or value of arguments:
defmodule MyApp.Notifier do
def notify(%User{email: nil} = user, _message) do
Logger.warning("No email for user #{user.id}, skipping notification")
{:error, :no_email}
end
def notify(%User{} = user, message) do
Mailer.deliver(to: user.email, body: message)
end
def notify({:admin, email}, message) do
Mailer.deliver(to: email, subject: "[ADMIN] " <> message.subject, body: message)
end
end
try/rescue for Expected Errorstry/rescue is reserved for exceptions from code outside your control (third-party libraries that raise instead of returning error tuples). It is not idiomatic for expected application errors:
# BAD: using rescue for control flow
def parse_integer(str) do
try do
{:ok, String.to_integer(str)}
rescue
ArgumentError -> {:error, :invalid_integer}
end
end
# GOOD: use functions that return ok/error tuples
def parse_integer(str) do
case Integer.parse(str) do
{value, ""} -> {:ok, value}
_ -> {:error, :invalid_integer}
end
end
Validate data once, at the changeset layer. Do not duplicate validation logic in controllers, LiveView callbacks, or service modules.
Schema defines shape → Changeset validates → Repo uses same changeset → Forms display errors
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
field :role, Ecto.Enum, values: [:user, :admin], default: :user
field :password, :string, virtual: true
field :hashed_password, :string
has_one :profile, MyApp.Accounts.Profile
timestamps()
end
@required [:email, :name, :password]
@optional [:role]
def changeset(user, attrs) do
user
|> cast(attrs, @required ++ @optional)
|> validate_required(@required)
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email")
|> validate_length(:password, min: 8, max: 72)
|> validate_inclusion(:role, [:user, :admin])
|> unique_constraint(:email)
|> put_password_hash()
end
defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pw}} = cs) do
put_change(cs, :hashed_password, Bcrypt.hash_pwd_salt(pw))
end
defp put_password_hash(cs), do: cs
end
The context function applies the changeset and returns {:ok, user} or {:error, changeset}. The controller routes on that result — no extra validation:
defmodule MyApp.Accounts do
def register_user(attrs) do
%User{} |> User.changeset(attrs) |> Repo.insert()
end
end
defmodule MyAppWeb.RegistrationController do
use MyAppWeb, :controller
alias MyApp.Accounts
def new(conn, _params) do
changeset = Accounts.User.changeset(%Accounts.User{}, %{})
render(conn, :new, changeset: to_form(changeset))
end
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, _user} ->
conn
|> put_flash(:info, "Account created!")
|> redirect(to: ~p"/login")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: to_form(changeset))
end
end
end
<.simple_form for={@changeset} action={~p"/register"}>
<.input field={@changeset[:email]} label="Email" />
<.input field={@changeset[:name]} label="Name" />
<.input field={@changeset[:password]} type="password" label="Password" />
<:actions><.button>Create account</.button></:actions>
</.simple_form>
The changeset carries both current values and errors — no separate validation layer.
# BAD: validation in both controller and changeset
def create(conn, %{"user" => params}) do
if String.length(params["password"]) < 8 do # duplicated from changeset
conn |> put_flash(:error, "Password too short") |> render(:new)
else
case Accounts.register_user(params) do
{:ok, _} -> redirect(conn, to: ~p"/login")
{:error, cs} -> render(conn, :new, changeset: to_form(cs))
end
end
end
Let the changeset own all validation. The controller's only job is to call the context function and route based on {:ok, _} or {:error, changeset}.
cast vs change# cast/4 — external/untrusted data: filters fields, type-converts
user |> cast(params, [:email, :name])
# change/2 — internal/already-valid data: no filtering
user |> change(last_login_at: DateTime.utc_now())
Not all data needs a DB table. Use embedded schemas for search forms, filter panels, and API query params — they get full changeset validation.
defmodule MyApp.Search.UserFilter do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :query, :string
field :role, Ecto.Enum, values: [:user, :admin]
field :created_after, :date
field :page, :integer, default: 1
field :per_page, :integer, default: 20
end
def changeset(filter \\ %__MODULE__{}, attrs) do
filter
|> cast(attrs, [:query, :role, :created_after, :page, :per_page])
|> validate_number(:page, greater_than: 0)
|> validate_inclusion(:per_page, [10, 20, 50, 100])
end
end
# In a LiveView or controller
def handle_event("filter", %{"user_filter" => params}, socket) do
case UserFilter.changeset(socket.assigns.filter, params) do
%{valid?: true} = cs ->
filter = Ecto.Changeset.apply_changes(cs)
users = Accounts.list_users(filter)
{:noreply, assign(socket, users: users, filter: filter)}
changeset ->
{:noreply, assign(socket, filter_changeset: changeset)}
end
end
Schemaless changesets handle one-off validation without a module:
def validate_search_params(params) do
types = %{query: :string, limit: :integer}
{%{}, types}
|> Ecto.Changeset.cast(params, Map.keys(types))
|> Ecto.Changeset.validate_required([:query])
|> Ecto.Changeset.validate_number(:limit, greater_than: 0, less_than_or_equal_to: 100)
end
Pipe when chaining data transformations where each step passes its result to the next:
# Good use of pipe: transforming a value through a sequence of steps
def normalize_email(email) do
email
|> String.trim()
|> String.downcase()
|> String.replace(~r/\+.*@/, "@")
end
Avoid piping a single function call — it adds visual noise without clarity benefit:
# BAD: unnecessary pipe
result = value |> some_function()
# GOOD: direct call
result = some_function(value)
Avoid piping side effects that don't transform data:
# BAD: pipe into a side effect
user |> send_welcome_email()
# GOOD: explicit call
send_welcome_email(user)
Elixir provides two access patterns with different semantics:
# Dot access — raises KeyError if key missing
# Use for required fields on known structs
user.email # raises if :email key not present
config.timeout # use when the field must exist
# Bracket access — returns nil if key missing
# Use for optional fields on dynamic maps
params[:email] # returns nil if missing, no crash
opts[:timeout] # safe for optional config keys
Struct fields always use dot access — structs enforce their shape at compile time, so a missing key is a programming error:
# Always use dot access for structs
%User{} = user
user.email # correct
user[:email] # valid but unusual — prefer dot for structs
Dynamic maps from external sources use bracket access for optional fields:
def build_query(filters) do
base_query = from(u in User)
base_query
|> maybe_filter_role(filters[:role])
|> maybe_filter_after(filters[:created_after])
end
Apply core:anti-fabrication when generating code examples or making claims about library behavior. Verify function signatures and return types against actual documentation. Do not fabricate error codes, changeset validator names, or Ecto API details.
{:ok, result} / {:error, reason} tuples to enable exhaustive pattern matchingwith for multi-step operations where each step depends on the previousembedded_schema or schemaless changesets for non-DB data that still needs validationtry/rescue for third-party code that raises; use tagged tuples for your own error paths