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
Write a function that safely divides two numbers, returning
{:ok, result}or{:error, reason}Use
withto validate a user registration form (email, password, age). Each field should have its own validation functionCreate a custom exception
InsufficientFundsErrorwith amount and balance fieldsImplement a file reading function that uses try/rescue/after to ensure the file is always closed
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.