home / skills / j-morgan6 / elixir-phoenix-guide / elixir-essentials

elixir-essentials skill

/skills/elixir-essentials

This skill helps Elixir code stay robust and idiomatic by enforcing pattern matching, @impl, with, and pipe usage across modules.

npx playbooks add skill j-morgan6/elixir-phoenix-guide --skill elixir-essentials

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
9.1 KB
---
name: elixir-essentials
description: MANDATORY for ALL Elixir code changes. Invoke before writing any .ex or .exs file.
file_patterns:
  - "**/*.ex"
  - "**/*.exs"
auto_suggest: true
---

# Elixir Essentials

## RULES — Follow these with no exceptions

1. **Use pattern matching over if/else** for control flow and data extraction
2. **Add @impl true** before every callback function (mount, handle_event, handle_info, etc.)
3. **Return {:ok, result} | {:error, reason} tuples** for fallible operations
4. **Use `with` for 2+ sequential fallible operations** instead of nested case
5. **Use the pipe operator** for 2+ chained transformations
6. **Never nest if/else statements** — use case, cond, or multi-clause functions
7. **Predicate functions end with `?`**, dangerous functions end with `!`
8. **Let it crash** — don't write defensive code for impossible states

---

## Pattern Matching

Pattern matching is the primary control flow mechanism in Elixir. Prefer it over conditional statements.

### Prefer Pattern Matching Over if/else

**Bad:**
```elixir
def process(result) do
  if result.status == :ok do
    result.data
  else
    nil
  end
end
```

**Good:**
```elixir
def process(%{status: :ok, data: data}), do: data
def process(_), do: nil
```

### Use Case for Multiple Patterns

**Bad:**
```elixir
def handle_response(response) do
  if response.status == 200 do
    {:ok, response.body}
  else if response.status == 404 do
    {:error, :not_found}
  else
    {:error, :unknown}
  end
end
```

**Good:**
```elixir
def handle_response(%{status: 200, body: body}), do: {:ok, body}
def handle_response(%{status: 404}), do: {:error, :not_found}
def handle_response(_), do: {:error, :unknown}
```

## Pipe Operator

Use the pipe operator `|>` to chain function calls for improved readability.

### Basic Piping

**Bad:**
```elixir
String.upcase(String.trim(user_input))
```

**Good:**
```elixir
user_input
|> String.trim()
|> String.upcase()
```

### Pipe into Function Heads

**Bad:**
```elixir
def process_user(user) do
  validated = validate_user(user)
  transformed = transform_user(validated)
  save_user(transformed)
end
```

**Good:**
```elixir
def process_user(user) do
  user
  |> validate_user()
  |> transform_user()
  |> save_user()
end
```

## With Statement

Use `with` for sequential operations that can fail.

**Bad:**
```elixir
def create_post(params) do
  case validate_params(params) do
    {:ok, valid_params} ->
      case create_changeset(valid_params) do
        {:ok, changeset} ->
          Repo.insert(changeset)
        error -> error
      end
    error -> error
  end
end
```

**Good:**
```elixir
def create_post(params) do
  with {:ok, valid_params} <- validate_params(params),
       {:ok, changeset} <- create_changeset(valid_params),
       {:ok, post} <- Repo.insert(changeset) do
    {:ok, post}
  end
end
```

### With Statement - Inline Error Handling

Handle specific errors in the else block.

```elixir
def transfer_money(from_id, to_id, amount) do
  with {:ok, from_account} <- get_account(from_id),
       {:ok, to_account} <- get_account(to_id),
       :ok <- validate_balance(from_account, amount),
       {:ok, _} <- debit(from_account, amount),
       {:ok, _} <- credit(to_account, amount) do
    {:ok, :transfer_complete}
  else
    {:error, :insufficient_funds} ->
      {:error, "Not enough money in account"}

    {:error, :not_found} ->
      {:error, "Account not found"}

    error ->
      {:error, "Transfer failed: #{inspect(error)}"}
  end
end
```

## Guards

Use guards for simple type and value checks in function heads.

```elixir
def calculate(x) when is_integer(x) and x > 0 do
  x * 2
end

def calculate(_), do: {:error, :invalid_input}
```

## List Comprehensions

Use `for` comprehensions for complex transformations and filtering.

**Bad (multiple passes):**
```elixir
list
|> Enum.map(&transform/1)
|> Enum.filter(&valid?/1)
|> Enum.map(&format/1)
```

**Good (single pass):**
```elixir
for item <- list,
    transformed = transform(item),
    valid?(transformed) do
  format(transformed)
end
```

## Naming Conventions

- Module names: `PascalCase`
- Function names: `snake_case`
- Variables: `snake_case`
- Atoms: `:snake_case`
- Predicate functions end with `?`: `valid?`, `empty?`
- Dangerous functions end with `!`: `save!`, `update!`

## Tagged Tuples for Error Handling

The idiomatic way to handle success and failure in Elixir.

```elixir
def fetch_user(id) do
  case Repo.get(User, id) do
    nil -> {:error, :not_found}
    user -> {:ok, user}
  end
end

# Usage
case fetch_user(123) do
  {:ok, user} -> IO.puts("Found: #{user.name}")
  {:error, :not_found} -> IO.puts("User not found")
end
```

## Case Statements

Pattern match on results.

```elixir
def process_upload(file) do
  case save_file(file) do
    {:ok, path} ->
      Logger.info("File saved to #{path}")
      create_record(path)

    {:error, :invalid_format} ->
      {:error, "File format not supported"}

    {:error, reason} ->
      Logger.error("Upload failed: #{inspect(reason)}")
      {:error, "Upload failed"}
  end
end
```

## Bang Functions

Functions ending with `!` raise errors instead of returning tuples.

```elixir
# Returns {:ok, user} or {:error, changeset}
def create_user(attrs) do
  %User{}
  |> User.changeset(attrs)
  |> Repo.insert()
end

# Returns user or raises
def create_user!(attrs) do
  %User{}
  |> User.changeset(attrs)
  |> Repo.insert!()
end

# Usage
try do
  user = create_user!(invalid_attrs)
  IO.puts("Created #{user.name}")
rescue
  e in Ecto.InvalidChangesetError ->
    IO.puts("Failed: #{inspect(e)}")
end
```

## Try/Rescue

Catch exceptions when needed (use sparingly).

```elixir
def parse_json(string) do
  try do
    {:ok, Jason.decode!(string)}
  rescue
    Jason.DecodeError -> {:error, :invalid_json}
  end
end
```

## Supervision Trees

Let processes fail and restart (preferred over defensive coding).

```elixir
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MyApp.Repo,
      MyAppWeb.Endpoint,
      {MyApp.Worker, []}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
```

## GenServer Error Handling

Handle errors in GenServer callbacks.

```elixir
def handle_call(:risky_operation, _from, state) do
  case perform_operation() do
    {:ok, result} ->
      {:reply, {:ok, result}, update_state(state, result)}

    {:error, reason} ->
      Logger.error("Operation failed: #{inspect(reason)}")
      {:reply, {:error, reason}, state}
  end
end

# Let it crash for unexpected errors
def handle_cast(:dangerous_work, state) do
  # If this raises, supervisor will restart the process
  result = dangerous_function!()
  {:noreply, Map.put(state, :result, result)}
end
```

## Validation Errors

Return clear, actionable error messages.

```elixir
def validate_image_upload(file) do
  with :ok <- validate_file_type(file),
       :ok <- validate_file_size(file),
       :ok <- validate_dimensions(file) do
    {:ok, file}
  else
    {:error, :invalid_type} ->
      {:error, "Only JPEG, PNG, and GIF files are allowed"}

    {:error, :too_large} ->
      {:error, "File must be less than 10MB"}

    {:error, :invalid_dimensions} ->
      {:error, "Image must be at least 100x100 pixels"}
  end
end
```

## Changeset Errors

Extract and format Ecto changeset errors.

```elixir
def changeset_errors(changeset) do
  Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", to_string(value))
    end)
  end)
end

# Usage
case create_user(attrs) do
  {:ok, user} -> {:ok, user}
  {:error, changeset} ->
    errors = changeset_errors(changeset)
    {:error, errors}
end
```

## Early Returns

Use pattern matching in function heads for early returns.

```elixir
def process_data(nil), do: {:error, :no_data}
def process_data([]), do: {:error, :empty_list}
def process_data(data) when is_list(data) do
  # Process the list
  {:ok, Enum.map(data, &transform/1)}
end
```

## Avoid Defensive Programming

Don't check for things that can't happen. Let it crash.

**Bad (defensive):**
```elixir
def get_username(user) do
  if user && user.name do
    user.name
  else
    "Unknown"
  end
end
```

**Good (trust your types):**
```elixir
def get_username(%User{name: name}), do: name
```

If the user is nil or doesn't have a name, it's a bug that should crash and be fixed.

## Documentation

Use `@doc` for public functions and `@moduledoc` for modules.

```elixir
defmodule MyModule do
  @moduledoc """
  This module handles user operations.
  """

  @doc """
  Fetches a user by ID.

  Returns `{:ok, user}` or `{:error, :not_found}`.
  """
  def fetch_user(id), do: # ...
end
```

## Immutability

All data structures are immutable. Functions return new values rather than modifying in place.

```elixir
# Always returns a new list
list = [1, 2, 3]
new_list = [0 | list]  # [0, 1, 2, 3]
# list is still [1, 2, 3]
```

## Anonymous Functions

Use the capture operator `&` for concise anonymous functions.

**Verbose:**
```elixir
Enum.map(list, fn x -> x * 2 end)
```

**Concise:**
```elixir
Enum.map(list, &(&1 * 2))
```

**Named function capture:**
```elixir
Enum.map(users, &User.format/1)
```

Overview

This skill enforces a compact set of Elixir idioms and safety patterns that must be applied before writing any .ex or .exs file. It codifies Elixir best practices like pattern matching, tagged tuples, pipe usage, and supervision-first error handling. Use it as a checklist to keep code idiomatic, maintainable, and aligned with OTP conventions.

How this skill works

The skill inspects Elixir source for common anti-patterns and missing conventions: excessive if/else nesting, missing @impl on callbacks, absence of tagged-tuple returns, and misuse of pipes or with for sequential fallible operations. It highlights places to replace defensive checks with pattern matching, recommends when to let processes crash under supervision, and suggests naming and punctuation conventions (?, !). It does not modify code automatically but provides actionable guidance to apply before committing changes.

When to use it

  • Mandatory for every change that adds or edits .ex or .exs files
  • When implementing callbacks (GenServer, LiveView, Phoenix) to ensure @impl usage
  • When writing fallible operations that should return {:ok, value} | {:error, reason}
  • When converting nested case/if chains into with or multi-clause functions
  • When refactoring pipelines to use |> or list comprehensions instead of multiple Enum passes

Best practices

  • Prefer pattern matching and multi-clause function heads over if/else and nested conditionals
  • Add @impl true to every behaviour callback to catch signature mismatches
  • Return tagged tuples for fallible APIs and use with for 2+ sequential fallible steps
  • Use |> for chains of 2+ transformations; use for comprehensions to combine transform+filter in one pass
  • Name predicates with ? and raising helpers with !; favor let-it-crash and supervision trees over defensive checks

Example use cases

  • Validating and creating resources: use with to flow validate -> build_changeset -> Repo.insert returning {:ok, resource} or formatted errors
  • Refactoring HTTP response handling: replace status checks with multiple function heads matching status atoms
  • GenServer callbacks: add @impl true, return tagged tuples on failure, and let supervisor restart on unexpected exceptions
  • Processing collections: convert multiple Enum.map/filter/map to a single for comprehension for efficiency
  • Error reporting: extract and format Ecto changeset errors into user-friendly messages before returning {:error, errors}

FAQ

Do I always use with for any fallible operation?

Use with when you have two or more sequential fallible steps. For single fallible calls a case or pattern match in the function head is often clearer.

When should I use bang (!) functions?

Reserve bang functions for internal or scripting contexts where you want exceptions to propagate. Public API functions should prefer tagged tuples for predictable error handling.