Elixir Fundamentals: Functional Programming and OTP · Processes, OTP and Fault Tolerance

Errors, Tagged Tuples and 'Let It Crash'

Use {:ok, value} / {:error, reason} for expected failures, raise and rescue for exceptional ones, define custom exceptions, and understand why Elixir code often lets processes crash and restarts them instead of defending against every error.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A file-importer core that returns tagged tuples for expected problems, raises a custom exception for a broken invariant, and shows the convention of function vs function! APIs.

The idea

Elixir has two error styles:

  • Expected failures (bad input, missing records, network errors) return {:ok, value} or {:error, reason}. Callers pattern-match.
  • Exceptional situations raise exceptions (raise ArgumentError, "...", or your own defexception). Rescue them with try/rescue only at boundaries.

Many libraries offer pairs: File.read/1 returns tuples; File.read!/1 returns the value or raises. Use the bang version when failure means the program can't sensibly continue.

"Let it crash": instead of wrapping every operation in defensive code, Elixir apps run work in isolated processes watched by supervisors. If a process hits an unexpected state, it crashes and the supervisor restarts it in a known-good state. Defensive code goes where you can actually do something useful (show the user an error, retry); everything else is allowed to fail fast.

The code

Tagged tuples, bang functions and custom exceptions
defmodule ImportError do
  defexception [:line, :reason]

  @impl true
  def message(%{line: line, reason: reason}), do: "line #{line}: #{reason}"
end

defmodule Importer do
  def parse_line(line) do
    case String.split(line, ",") do
      [name, qty] ->
        case Integer.parse(String.trim(qty)) do
          {n, ""} when n >= 0 -> {:ok, {String.trim(name), n}}
          _ -> {:error, :bad_quantity}
        end

      _ ->
        {:error, :wrong_column_count}
    end
  end

  def parse_line!(line, number) do
    case parse_line(line) do
      {:ok, row} -> row
      {:error, reason} -> raise ImportError, line: number, reason: reason
    end
  end

  def import(text) do
    text
    |> String.split("\n", trim: true)
    |> Enum.with_index(1)
    |> Enum.reduce(%{rows: [], errors: []}, fn {line, n}, acc ->
      case parse_line(line) do
        {:ok, row} -> %{acc | rows: [row | acc.rows]}
        {:error, reason} -> %{acc | errors: [{n, reason} | acc.errors]}
      end
    end)
    |> then(fn acc -> %{rows: Enum.reverse(acc.rows), errors: Enum.reverse(acc.errors)} end)
  end
end

csv = "apples, 12\npears, ten\nplums\ncherries, 40"
IO.inspect(Importer.import(csv))

IO.inspect(Importer.parse_line!("figs, 3", 1))

try do
  Importer.parse_line!("dates, -4", 7)
rescue
  e in ImportError -> IO.puts("rescued: " <> Exception.message(e))
end

{:error, reason} = File.read("/definitely/missing.txt")
IO.inspect(reason, label: "File.read")

Output

%{
  rows: [{"apples", 12}, {"cherries", 40}],
  errors: [{2, :bad_quantity}, {3, :wrong_column_count}]
}
{"figs", 3}
rescued: line 7: bad_quantity
File.read: :enoent

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • try/rescue everywhere, like exceptions in other languages; prefer tagged tuples and let supervisors handle the unexpected.
  • Raising for expected cases (a user typed a bad value); return {:error, reason}.
  • Swallowing errors with catch-all clauses that hide bugs.
  • Mixing styles within one API; offer fun/1 (tuples) and fun!/1 (raises) consistently.

Key points

  • Expected failures → {:ok, v} / {:error, r}; exceptional ones → raise.
  • fun!/1 variants raise instead of returning error tuples.
  • defexception for custom exceptions; rescue only at boundaries.
  • "Let it crash": isolate work in supervised processes and restart cleanly.

Further reading: try, catch, and rescue.

Practice questions

What does 'let it crash' mean in Elixir?

Answer

Rather than defending against every possible error, code assumes the happy path and lets a process crash on unexpected states. A supervisor restarts it in a known-good state, which keeps the system running and the code simpler. Expected errors are still handled with tagged tuples.

What's the convention behind File.read/1 and File.read!/1?

Answer

The plain version returns {:ok, value} or {:error, reason}; the bang version returns the value directly or raises an exception on failure.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?