Error Handling

Master error handling in Elixir: try/rescue/catch, error tuples, the with macro, and the "let it crash" philosophy.

Error Handling Philosophy

Elixir follows Erlang's "let it crash" philosophy:

  • Don't defensively code for every error
  • Let supervisors restart failed processes
  • Use pattern matching for expected failures
  • Reserve exceptions for truly exceptional cases

Tagged Tuples Pattern

The idiomatic way to handle expected failures:

{:ok, result} / {:error, reason}

defmodule FileReader do
  def read(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, String.upcase(content)}
      {:error, reason} -> {:error, reason}
    end
  end
end

case FileReader.read("data.txt") do
  {:ok, content} -> IO.puts(content)
  {:error, :enoent} -> IO.puts("File not found")
  {:error, reason} -> IO.puts("Error: #{reason}")
end

Pattern Matching on Results

# Happy path
{:ok, user} = Database.get_user(1)

# Guarded matching
case Database.get_user(1) do
  {:ok, user} -> "Found: #{user.name}"
  {:error, :not_found} -> "User doesn't exist"
  {:error, reason} -> "Database error: #{reason}"
end

Multi-value Returns

defmodule Parser do
  def parse(string) do
    case Integer.parse(string) do
      {num, ""} -> {:ok, num}
      {num, _rest} -> {:ok, num, :partial}
      :error -> {:error, :invalid_integer}
    end
  end
end

Parser.parse("42")      # => {:ok, 42}
Parser.parse("42abc")   # => {:ok, 42, :partial}
Parser.parse("abc")     # => {:error, :invalid_integer}

The with Special Form

Chain operations that return tagged tuples - stops at first error.

Basic with

defmodule UserService do
  def register(params) do
    with {:ok, email} <- validate_email(params["email"]),
         {:ok, password} <- validate_password(params["password"]),
         {:ok, user} <- create_user(email, password) do
      {:ok, user}
    else
      {:error, reason} -> {:error, reason}
    end
  end
  
  defp validate_email(email) when is_binary(email) and byte_size(email) > 3 do
    {:ok, email}
  end
  defp validate_email(_), do: {:error, :invalid_email}
  
  defp validate_password(pass) when is_binary(pass) and byte_size(pass) >= 8 do
    {:ok, pass}
  end
  defp validate_password(_), do: {:error, :password_too_short}
  
  defp create_user(email, password) do
    # Database logic
    {:ok, %{email: email, id: 1}}
  end
end

UserService.register(%{"email" => "alice@example.com", "password" => "secret123"})
# => {:ok, %{email: "alice@example.com", id: 1}}

UserService.register(%{"email" => "a", "password" => "secret123"})
# => {:error, :invalid_email}

with Without else

If you don't care about the error:

def process_data(data) do
  with {:ok, parsed} <- parse(data),
       {:ok, validated} <- validate(parsed),
       {:ok, result} <- transform(validated) do
    {:ok, result}
  end
end
# Returns first error tuple if any step fails

with and Pattern Matching

defmodule OrderProcessor do
  def process(order_id) do
    with {:ok, order} <- fetch_order(order_id),
         {:ok, _} <- validate_stock(order.items),
         {:ok, payment} <- process_payment(order),
         {:ok, _} <- ship_order(order) do
      {:ok, "Order #{order_id} completed"}
    else
      {:error, :not_found} -> {:error, "Order not found"}
      {:error, :out_of_stock} -> {:error, "Item out of stock"}
      {:error, :payment_failed} -> {:error, "Payment declined"}
      {:error, reason} -> {:error, reason}
    end
  end
end

with with Regular Matching

Mix pattern matching with tagged tuples:

def calculate(a, b) do
  with {:ok, num_a} <- parse_int(a),
       {:ok, num_b} <- parse_int(b),
       sum = num_a + num_b,  # Regular match, always succeeds
       true <- sum > 0 do
    {:ok, sum}
  else
    false -> {:error, :negative_result}
    error -> error
  end
end

Exceptions

For truly exceptional cases - not for control flow.

Raising Exceptions

# Raise with string
raise "Something went wrong"

# Raise with module
raise ArgumentError, message: "Invalid argument"

# Common exceptions
raise RuntimeError, message: "Runtime error"
raise ArithmeticError, message: "Bad math"
raise KeyError, key: :missing_key, term: %{}

try/rescue

Catch and handle exceptions:

try do
  1 / 0
rescue
  ArithmeticError -> "Cannot divide by zero"
end
# => "Cannot divide by zero"

# Match on exception
try do
  dangerous_operation()
rescue
  e in RuntimeError -> "Runtime: #{e.message}"
  e in ArgumentError -> "Argument: #{e.message}"
  e -> "Unknown: #{inspect(e)}"
end

try/rescue with Variables

defmodule Calculator do
  def divide(a, b) do
    try do
      result = a / b
      {:ok, result}
    rescue
      ArithmeticError -> {:error, :division_by_zero}
    end
  end
end

Calculator.divide(10, 2)  # => {:ok, 5.0}
Calculator.divide(10, 0)  # => {:error, :division_by_zero}

after Clause

Always executes - like finally:

{:ok, file} = File.open("test.txt", [:write])

try do
  IO.write(file, "Hello")
  raise "Oops"
rescue
  _ -> "Error occurred"
after
  File.close(file)  # Always executed
end

try/catch

For thrown values (not exceptions):

try do
  throw(:error)
rescue
  error -> "Rescued: #{error}"
catch
  value -> "Caught: #{value}"
end
# => "Caught: :error"

# Practical example
def find_even(list) do
  try do
    Enum.each(list, fn x ->
      if rem(x, 2) == 0, do: throw(x)
    end)
    nil
  catch
    value -> value
  end
end

find_even([1, 3, 4, 5])  # => 4

try/catch/rescue/after

All together:

try do
  risky_operation()
rescue
  e in RuntimeError -> handle_error(e)
catch
  :exit, reason -> handle_exit(reason)
  value -> handle_throw(value)
after
  cleanup()
end

Error Structs

Define custom exceptions:

Basic Error Module

defmodule MyError do
  defexception message: "default error"
end

raise MyError
# ** (MyError) default error

raise MyError, message: "custom message"
# ** (MyError) custom message

Error with Fields

defmodule ValidationError do
  defexception [:field, :message]
  
  @impl true
  def exception(field: field) do
    %ValidationError{
      field: field,
      message: "Invalid value for #{field}"
    }
  end
  
  def exception(field: field, message: message) do
    %ValidationError{field: field, message: message}
  end
end

raise ValidationError, field: :email
# ** (ValidationError) Invalid value for email

raise ValidationError, field: :age, message: "Must be positive"
# ** (ValidationError) Must be positive

Custom Exception with Implementation

defmodule NotFoundError do
  defexception [:resource, :id]
  
  @impl true
  def message(%{resource: resource, id: id}) do
    "#{resource} with id #{id} not found"
  end
end

raise NotFoundError, resource: "User", id: 123
# ** (NotFoundError) User with id 123 not found

Bang Functions (!)

Conventions for error handling:

Function Pairs

# Returns {:ok, result} | {:error, reason}
File.read("file.txt")
# => {:ok, "content"} or {:error, :enoent}

# Raises on error
File.read!("file.txt")
# => "content" or raises File.Error

# More examples
Map.fetch(map, :key)   # => {:ok, value} | :error
Map.fetch!(map, :key)  # => value or raises KeyError

Integer.parse("42")    # => {42, ""} | :error
String.to_integer("42")  # => 42 or raises ArgumentError

Creating Bang Functions

defmodule Database do
  def get_user(id) do
    # Returns {:ok, user} | {:error, reason}
    case find_user(id) do
      nil -> {:error, :not_found}
      user -> {:ok, user}
    end
  end
  
  def get_user!(id) do
    case get_user(id) do
      {:ok, user} -> user
      {:error, reason} -> raise "Failed to get user: #{reason}"
    end
  end
  
  defp find_user(_id), do: nil  # Simplified
end

Database.get_user(1)   # => {:error, :not_found}
Database.get_user!(1)  # Raises error

Error Handling Patterns

Railway-Oriented Programming

Chain operations with consistent error handling:

defmodule Pipeline do
  def process(data) do
    data
    |> step1()
    |> bind(&step2/1)
    |> bind(&step3/1)
    |> bind(&step4/1)
  end
  
  defp bind({:ok, value}, func), do: func.(value)
  defp bind({:error, _} = error, _func), do: error
  
  defp step1(data), do: {:ok, String.trim(data)}
  defp step2(data), do: {:ok, String.upcase(data)}
  defp step3(""), do: {:error, :empty_string}
  defp step3(data), do: {:ok, String.reverse(data)}
  defp step4(data), do: {:ok, data}
end

Pipeline.process("  hello  ")  # => {:ok, "OLLEH"}
Pipeline.process("  ")         # => {:error, :empty_string}

Defensive Pattern Matching

defmodule UserService do
  def get_user_email(user_id) do
    with {:ok, user} <- fetch_user(user_id),
         %{email: email} <- user do
      {:ok, email}
    else
      nil -> {:error, :user_has_no_email}
      {:error, reason} -> {:error, reason}
    end
  end
end

Try-Catch-Throw Pattern

defmodule EarlyExit do
  def process_list(list) do
    try do
      result = Enum.map(list, fn
        x when x < 0 -> throw({:error, :negative_number})
        x -> x * 2
      end)
      {:ok, result}
    catch
      {:error, reason} -> {:error, reason}
    end
  end
end

EarlyExit.process_list([1, 2, 3])     # => {:ok, [2, 4, 6]}
EarlyExit.process_list([1, -2, 3])   # => {:error, :negative_number}

"Let It Crash" Philosophy

When to Let It Crash

# DON'T: Defensive programming everywhere
defmodule BadExample do
  def divide(a, b) do
    cond do
      !is_number(a) -> {:error, :invalid_a}
      !is_number(b) -> {:error, :invalid_b}
      b == 0 -> {:error, :division_by_zero}
      true -> {:ok, a / b}
    end
  end
end

# DO: Trust your inputs, let type errors crash
defmodule GoodExample do
  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(a, b) when is_number(a) and is_number(b) do
    {:ok, a / b}
  end
end

Supervisor Will Restart

defmodule Worker do
  use GenServer
  
  def start_link(_) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
  
  def init(state) do
    {:ok, state}
  end
  
  # If this crashes, supervisor restarts it
  def handle_call(:risky_operation, _from, state) do
    result = risky_external_call()  # May raise
    {:reply, result, state}
  end
end

# In your supervision tree
children = [
  {Worker, []}
]

Supervisor.start_link(children, strategy: :one_for_one)

Crash for Impossible States

defmodule Order do
  def complete(order) do
    case order.status do
      :pending -> {:ok, %{order | status: :completed}}
      :completed -> {:error, :already_completed}
      :cancelled -> {:error, :order_cancelled}
      # If we get here, data is corrupted - let it crash!
      other -> raise "Invalid order status: #{other}"
    end
  end
end

Practical Examples

HTTP Client with Error Handling

defmodule APIClient do
  def fetch_user(id) do
    with {:ok, response} <- HTTPoison.get("https://api.example.com/users/#{id}"),
         {:ok, body} <- parse_response(response),
         {:ok, user} <- decode_user(body) do
      {:ok, user}
    else
      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, {:http_error, reason}}
      {:error, reason} ->
        {:error, reason}
    end
  end
  
  defp parse_response(%{status_code: 200, body: body}), do: {:ok, body}
  defp parse_response(%{status_code: 404}), do: {:error, :not_found}
  defp parse_response(%{status_code: code}), do: {:error, {:http_status, code}}
  
  defp decode_user(body) do
    case Jason.decode(body) do
      {:ok, user} -> {:ok, user}
      {:error, _} -> {:error, :invalid_json}
    end
  end
end

File Processing Pipeline

defmodule FileProcessor do
  def process(path) do
    with {:ok, content} <- File.read(path),
         {:ok, lines} <- validate_content(content),
         {:ok, data} <- parse_lines(lines),
         {:ok, result} <- transform_data(data) do
      save_result(result)
    else
      {:error, :enoent} -> 
        {:error, "File not found: #{path}"}
      {:error, :empty_file} -> 
        {:error, "File is empty"}
      {:error, reason} -> 
        {:error, "Processing failed: #{inspect(reason)}"}
    end
  end
  
  defp validate_content(""), do: {:error, :empty_file}
  defp validate_content(content) do
    {:ok, String.split(content, "\n")}
  end
  
  defp parse_lines(lines) do
    parsed = Enum.map(lines, &String.trim/1)
    {:ok, parsed}
  end
  
  defp transform_data(data) do
    {:ok, Enum.map(data, &String.upcase/1)}
  end
  
  defp save_result(result) do
    # Save logic
    {:ok, result}
  end
end

Transaction-like Operations

defmodule BankTransfer do
  def transfer(from_account, to_account, amount) do
    with {:ok, from_acc} <- validate_account(from_account),
         {:ok, to_acc} <- validate_account(to_account),
         {:ok, _} <- check_balance(from_acc, amount),
         {:ok, from_acc} <- debit(from_acc, amount),
         {:ok, to_acc} <- credit(to_acc, amount) do
      {:ok, {from_acc, to_acc}}
    else
      {:error, :invalid_account} = error -> error
      {:error, :insufficient_funds} = error -> error
      {:error, reason} -> {:error, {:transfer_failed, reason}}
    end
  end
  
  defp validate_account(nil), do: {:error, :invalid_account}
  defp validate_account(account), do: {:ok, account}
  
  defp check_balance(%{balance: balance}, amount) when balance >= amount do
    {:ok, :sufficient}
  end
  defp check_balance(_, _), do: {:error, :insufficient_funds}
  
  defp debit(account, amount) do
    {:ok, %{account | balance: account.balance - amount}}
  end
  
  defp credit(account, amount) do
    {:ok, %{account | balance: account.balance + amount}}
  end
end

Retry Logic

defmodule Retry do
  def call(func, retries \\ 3) do
    try do
      {:ok, func.()}
    rescue
      _ when retries > 0 ->
        :timer.sleep(1000)
        call(func, retries - 1)
      error ->
        {:error, error}
    end
  end
end

Retry.call(fn -> 
  HTTPoison.get("https://api.example.com/data")
end, 5)

Exercises

  1. Write a function that safely divides two numbers, returning {:ok, result} or {:error, reason}

  2. Use with to validate a user registration form (email, password, age). Each field should have its own validation function

  3. Create a custom exception InsufficientFundsError with amount and balance fields

  4. Implement a file reading function that uses try/rescue/after to ensure the file is always closed

  5. Build a pipeline that fetches data from multiple sources, handling errors at each step with railway-oriented programming

# Solutions

# 1. Safe division
defmodule SafeMath do
  def divide(_, 0), do: {:error, :division_by_zero}
  def divide(a, b) when is_number(a) and is_number(b) do
    {:ok, a / b}
  end
  def divide(_, _), do: {:error, :invalid_input}
end

# 2. User registration with `with`
defmodule UserRegistration do
  def register(params) do
    with {:ok, email} <- validate_email(params["email"]),
         {:ok, password} <- validate_password(params["password"]),
         {:ok, age} <- validate_age(params["age"]) do
      {:ok, %{email: email, password: password, age: age}}
    end
  end
  
  defp validate_email(email) when is_binary(email) do
    if String.contains?(email, "@") do
      {:ok, email}
    else
      {:error, :invalid_email}
    end
  end
  defp validate_email(_), do: {:error, :invalid_email}
  
  defp validate_password(pass) when is_binary(pass) and byte_size(pass) >= 8 do
    {:ok, pass}
  end
  defp validate_password(_), do: {:error, :password_too_short}
  
  defp validate_age(age) when is_integer(age) and age >= 18 do
    {:ok, age}
  end
  defp validate_age(_), do: {:error, :invalid_age}
end

# 3. Custom exception
defmodule InsufficientFundsError do
  defexception [:amount, :balance]
  
  @impl true
  def message(%{amount: amount, balance: balance}) do
    "Insufficient funds: tried to withdraw #{amount}, but balance is #{balance}"
  end
end

# Usage
# raise InsufficientFundsError, amount: 100, balance: 50

# 4. File reading with cleanup
defmodule SafeFileReader do
  def read(path) do
    file = File.open!(path)
    
    try do
      content = IO.read(file, :all)
      {:ok, content}
    rescue
      error -> {:error, error}
    after
      File.close(file)
    end
  end
end

# 5. Railway-oriented pipeline
defmodule DataPipeline do
  def fetch_all do
    with {:ok, users} <- fetch_users(),
         {:ok, orders} <- fetch_orders(),
         {:ok, products} <- fetch_products(),
         {:ok, result} <- combine_data(users, orders, products) do
      {:ok, result}
    else
      {:error, :users_failed} -> {:error, "Could not fetch users"}
      {:error, :orders_failed} -> {:error, "Could not fetch orders"}
      {:error, :products_failed} -> {:error, "Could not fetch products"}
      {:error, reason} -> {:error, reason}
    end
  end
  
  defp fetch_users do
    # Simulate API call
    {:ok, [%{id: 1, name: "Alice"}]}
  end
  
  defp fetch_orders do
    {:ok, [%{id: 1, user_id: 1, total: 100}]}
  end
  
  defp fetch_products do
    {:ok, [%{id: 1, name: "Widget", price: 10}]}
  end
  
  defp combine_data(users, orders, products) do
    {:ok, %{users: users, orders: orders, products: products}}
  end
end

Next Steps

Continue to 08-processes.md to learn about Elixir's concurrency model with lightweight processes, message passing, and GenServer.