I like to think of error handling in Elixir as a reliable postal service.
Every package either arrives successfully with its contents ({:ok, value}) or comes back with a clear note explaining the delivery failure ({:error, reason}).
Unlike languages that use exceptions as their primary error mechanism, Elixir treats errors as values that flow through your code like any other value.
That makes control flow explicit and easier to reason about, especially when you are learning.
In this article, we'll explore how this pattern works, why it's idiomatic Elixir, and how to use it to build reliable and readable programs.
Note: The examples in this article use Elixir 1.19.5. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- The Ok/Error Tuple Pattern
- Returning Results from Functions
- Handling Results at the Call Site
- Chaining Operations with with
- The Bang Convention
- Practical Patterns
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
Elixir encourages a style of error handling where functions return explicit values for success and failure.
I like to think of {:ok, value} and {:error, reason} as two lanes: your code stays in the success lane when things are fine, or moves to the error lane when something goes wrong.
The Elixir standard library, as well as most popular libraries, follow this convention consistently.
The Ok/Error Tuple Pattern
The Core Convention
One way I learned to read Elixir code is this: if a function can fail, it often returns a tuple with a status atom as the first element.
# Successful result
{:ok, "some value"}
{:ok, 42}
{:ok, %{name: "Alice", age: 30}}
# Error result
{:error, :not_found}
{:error, "invalid input"}
{:error, {:validation_failed, [:name, :email]}}
Testing in IEx:
iex> {:ok, "hello"}
{:ok, "hello"}
iex> {:error, :not_found}
{:error, :not_found}
iex> elem({:ok, 42}, 0)
:ok
iex> elem({:ok, 42}, 1)
42
Why Tagged Tuples?
I found this pattern especially useful because it integrates naturally with pattern matching:
# Without tagged tuples, you might check for nil
defmodule BadExample do
def find_user(id) do
# Returns nil on failure - ambiguous!
if id == 1, do: %{name: "Alice"}, else: nil
end
end
# With tagged tuples, the intent is clear
defmodule GoodExample do
def find_user(1), do: {:ok, %{name: "Alice"}}
def find_user(_id), do: {:error, :not_found}
end
Testing in IEx:
iex> GoodExample.find_user(1)
{:ok, %{name: "Alice"}}
iex> GoodExample.find_user(99)
{:error, :not_found}
iex> {:ok, user} = GoodExample.find_user(1)
{:ok, %{name: "Alice"}}
iex> "Found: #{user.name}"
"Found: Alice"
Standard Library Examples
I keep seeing this convention across the Elixir standard library:
defmodule StandardLibraryExamples do
def demonstrate_file_read do
case File.read("existing_file.txt") do
{:ok, contents} -> IO.puts("File contents: #{contents}")
{:error, :enoent} -> IO.puts("File does not exist")
{:error, reason} -> IO.puts("Error reading file: #{inspect(reason)}")
end
end
def demonstrate_integer_parse do
case Integer.parse("42") do
{number, ""} -> {:ok, number}
{number, _rest} -> {:ok, number} # partial parse
:error -> {:error, "not a number"}
end
end
def demonstrate_map_access do
map = %{name: "Alice"}
case Map.fetch(map, :name) do
{:ok, name} -> "Name is: #{name}"
:error -> "Key not found"
end
end
end
Testing in IEx:
iex> StandardLibraryExamples.demonstrate_file_read()
File does not exist
:ok
iex> StandardLibraryExamples.demonstrate_integer_parse()
{:ok, 42}
iex> StandardLibraryExamples.demonstrate_map_access()
"Name is: Alice"
# Direct module function calls for comparison:
iex> File.read("definitely_missing_file.txt")
{:error, :enoent}
iex> Map.fetch(%{name: "Alice"}, :name)
{:ok, "Alice"}
iex> Map.fetch(%{name: "Alice"}, :email)
:error
iex> Integer.parse("42")
{42, ""}
iex> Integer.parse("not a number")
:error
iex> Integer.parse("42abc")
{42, "abc"}
Returning Results from Functions
Basic Function Patterns
defmodule UserRepository do
@users %{
1 => %{id: 1, name: "Alice", email: "alice@example.com", active: true},
2 => %{id: 2, name: "Bob", email: "bob@example.com", active: false}
}
def find(id) do
case Map.fetch(@users, id) do
{:ok, user} -> {:ok, user}
:error -> {:error, :not_found}
end
end
def find_active(id) do
case find(id) do
{:ok, %{active: true} = user} -> {:ok, user}
{:ok, %{active: false}} -> {:error, :user_inactive}
{:error, reason} -> {:error, reason}
end
end
def list_all do
{:ok, Map.values(@users)}
end
end
Testing in IEx:
iex> UserRepository.find(1)
{:ok, %{id: 1, name: "Alice", email: "alice@example.com", active: true}}
iex> UserRepository.find(99)
{:error, :not_found}
iex> UserRepository.find_active(1)
{:ok, %{id: 1, name: "Alice", email: "alice@example.com", active: true}}
iex> UserRepository.find_active(2)
{:error, :user_inactive}
Validation Functions
defmodule UserValidator do
def validate_email(email) when is_binary(email) do
if String.contains?(email, "@") and String.contains?(email, ".") do
{:ok, String.downcase(email)}
else
{:error, {:invalid_email, email}}
end
end
def validate_email(_other) do
{:error, :email_must_be_string}
end
def validate_age(age) when is_integer(age) and age >= 0 and age <= 150 do
{:ok, age}
end
def validate_age(age) when is_integer(age) do
{:error, {:age_out_of_range, age}}
end
def validate_age(_other) do
{:error, :age_must_be_integer}
end
def validate_name(name) when is_binary(name) and byte_size(name) > 0 do
trimmed = String.trim(name)
if byte_size(trimmed) > 0 do
{:ok, trimmed}
else
{:error, :name_cannot_be_blank}
end
end
def validate_name(_other) do
{:error, :name_must_be_string}
end
end
Testing in IEx:
iex> UserValidator.validate_email("alice@example.com")
{:ok, "alice@example.com"}
iex> UserValidator.validate_email("ALICE@EXAMPLE.COM")
{:ok, "alice@example.com"}
iex> UserValidator.validate_email("not-an-email")
{:error, {:invalid_email, "not-an-email"}}
iex> UserValidator.validate_age(25)
{:ok, 25}
iex> UserValidator.validate_age(-5)
{:error, {:age_out_of_range, -5}}
iex> UserValidator.validate_name(" Alice ")
{:ok, "Alice"}
iex> UserValidator.validate_name(" ")
{:error, :name_cannot_be_blank}
Enriching Error Reasons
Error reasons can carry as much context as needed:
defmodule DataParser do
def parse_config(raw_config) when is_map(raw_config) do
with {:ok, host} <- extract_host(raw_config),
{:ok, port} <- extract_port(raw_config),
{:ok, timeout} <- extract_timeout(raw_config) do
{:ok, %{host: host, port: port, timeout: timeout}}
end
end
def parse_config(_other) do
{:error, {:invalid_input, "config must be a map"}}
end
defp extract_host(%{"host" => host}) when is_binary(host) and byte_size(host) > 0 do
{:ok, host}
end
defp extract_host(%{"host" => host}) do
{:error, {:invalid_field, :host, "must be a non-empty string, got: #{inspect(host)}"}}
end
defp extract_host(_map) do
{:error, {:missing_field, :host}}
end
defp extract_port(%{"port" => port}) when is_integer(port) and port > 0 and port <= 65535 do
{:ok, port}
end
defp extract_port(%{"port" => port}) do
{:error, {:invalid_field, :port, "must be integer 1-65535, got: #{inspect(port)}"}}
end
defp extract_port(_map) do
{:ok, 4000} # default port
end
defp extract_timeout(%{"timeout" => timeout}) when is_integer(timeout) and timeout > 0 do
{:ok, timeout}
end
defp extract_timeout(%{"timeout" => timeout}) do
{:error, {:invalid_field, :timeout, "must be a positive integer, got: #{inspect(timeout)}"}}
end
defp extract_timeout(_map) do
{:ok, 5000} # default timeout
end
end
Testing in IEx:
iex> DataParser.parse_config(%{"host" => "localhost", "port" => 8080})
{:ok, %{host: "localhost", port: 8080, timeout: 5000}}
iex> DataParser.parse_config(%{"host" => "localhost"})
{:ok, %{host: "localhost", port: 4000, timeout: 5000}}
iex> DataParser.parse_config(%{"host" => "", "port" => 8080})
{:error, {:invalid_field, :host, "must be a non-empty string, got: \"\""}}
iex> DataParser.parse_config(%{"port" => 8080})
{:error, {:missing_field, :host}}
iex> DataParser.parse_config("not a map")
{:error, {:invalid_input, "config must be a map"}}
Handling Results at the Call Site
Using case
The most explicit way to handle a result is with case:
defmodule UserController do
def show_user(user_id) do
case UserRepository.find(user_id) do
{:ok, user} ->
IO.puts("Found user: #{user.name} (#{user.email})")
{:error, :not_found} ->
IO.puts("Error: User #{user_id} does not exist")
{:error, reason} ->
IO.puts("Unexpected error: #{inspect(reason)}")
end
end
def create_user(params) do
case validate_and_create(params) do
{:ok, user} ->
IO.puts("Created user: #{inspect(user)}")
{:ok, user}
{:error, :validation_failed, errors} ->
IO.puts("Validation failed: #{inspect(errors)}")
{:error, :validation_failed, errors}
{:error, reason} ->
IO.puts("Failed to create user: #{inspect(reason)}")
{:error, reason}
end
end
defp validate_and_create(%{name: name, email: email}) do
with {:ok, valid_name} <- UserValidator.validate_name(name),
{:ok, valid_email} <- UserValidator.validate_email(email) do
{:ok, %{name: valid_name, email: valid_email}}
end
end
defp validate_and_create(_params) do
{:error, :missing_required_fields}
end
end
Testing in IEx:
iex> UserController.show_user(1)
Found user: Alice (alice@example.com)
:ok
iex> UserController.show_user(99)
Error: User 99 does not exist
:ok
iex> UserController.create_user(%{name: "Charlie", email: "charlie@example.com"})
Created user: %{email: "charlie@example.com", name: "Charlie"}
{:ok, %{email: "charlie@example.com", name: "Charlie"}}
iex> UserController.create_user(%{name: "", email: "charlie@example.com"})
Failed to create user: :name_must_be_string
{:error, :name_must_be_string}
Pattern Matching in Function Clauses
You can also handle results directly through function clause pattern matching:
defmodule ResultProcessor do
# Handle success
def process_result({:ok, value}) do
"Success: #{inspect(value)}"
end
# Handle specific errors
def process_result({:error, :not_found}) do
"The requested item was not found"
end
def process_result({:error, :unauthorized}) do
"You do not have permission to perform this action"
end
# Handle generic errors
def process_result({:error, reason}) do
"An error occurred: #{inspect(reason)}"
end
# Batch processing
def process_many(results) do
Enum.map(results, fn result ->
case result do
{:ok, value} -> {:processed, value}
{:error, reason} -> {:failed, reason}
end
end)
end
# Separate successes from errors
def partition_results(results) do
Enum.split_with(results, fn
{:ok, _} -> true
{:error, _} -> false
end)
end
end
Testing in IEx:
iex> ResultProcessor.process_result({:ok, %{id: 1, name: "Alice"}})
"Success: %{id: 1, name: \"Alice\"}"
iex> ResultProcessor.process_result({:error, :not_found})
"The requested item was not found"
iex> ResultProcessor.process_result({:error, :something_else})
"An error occurred: :something_else"
iex> results = [{:ok, 1}, {:error, :bad}, {:ok, 2}, {:error, :worse}, {:ok, 3}]
[{:ok, 1}, {:error, :bad}, {:ok, 2}, {:error, :worse}, {:ok, 3}]
iex> ResultProcessor.partition_results(results)
{[{:ok, 1}, {:ok, 2}, {:ok, 3}], [{:error, :bad}, {:error, :worse}]}
Extracting Values Safely
Sometimes it's useful to extract values directly or apply transformations:
defmodule SafeExtractor do
# Extract the value or return a default
def unwrap_or({:ok, value}, _default), do: value
def unwrap_or({:error, _reason}, default), do: default
# Transform the value inside {:ok, _}
def map({:ok, value}, fun), do: {:ok, fun.(value)}
def map({:error, _} = error, _fun), do: error
end
Testing in IEx:
iex> SafeExtractor.unwrap_or({:ok, "value"}, "default")
"value"
iex> SafeExtractor.unwrap_or({:error, :not_found}, "default")
"default"
iex> SafeExtractor.map({:ok, 5}, fn x -> x * 2 end)
{:ok, 10}
iex> SafeExtractor.map({:error, :bad}, fn x -> x * 2 end)
{:error, :bad}
Chaining Operations with with
I found with very helpful for chaining operations that return {:ok, value} or {:error, reason}. It stops at the first failure, which keeps the flow easy to follow.
Basic with Usage
defmodule RegistrationService do
def register_user(params) do
with {:ok, name} <- UserValidator.validate_name(params[:name]),
{:ok, email} <- UserValidator.validate_email(params[:email]),
{:ok, age} <- UserValidator.validate_age(params[:age]),
{:ok, user} <- create_user_record(name, email, age) do
{:ok, user}
end
end
defp create_user_record(name, email, age) do
# Simulate saving to database
user = %{id: :rand.uniform(1000), name: name, email: email, age: age}
{:ok, user}
end
end
Testing in IEx:
iex> RegistrationService.register_user(%{name: "Alice", email: "alice@example.com", age: 28})
{:ok, %{id: 742, name: "Alice", email: "alice@example.com", age: 28}}
iex> RegistrationService.register_user(%{name: "", email: "alice@example.com", age: 28})
{:error, :name_cannot_be_blank}
iex> RegistrationService.register_user(%{name: "Alice", email: "not-valid", age: 28})
{:error, {:invalid_email, "not-valid"}}
iex> RegistrationService.register_user(%{name: "Alice", email: "alice@example.com", age: -5})
{:error, {:age_out_of_range, -5}}
with and Custom Error Handling
The else clause in with lets you handle errors in one place:
defmodule OrderService do
def place_order(user_id, product_id, quantity) do
with {:ok, user} <- UserRepository.find_active(user_id),
{:ok, product} <- find_product(product_id),
{:ok, stock} <- check_stock(product, quantity),
{:ok, order} <- create_order(user, product, stock, quantity) do
{:ok, order}
else
{:error, :not_found} ->
{:error, "User or product not found"}
{:error, :user_inactive} ->
{:error, "User account is not active"}
{:error, {:insufficient_stock, available}} ->
{:error, "Only #{available} items in stock"}
{:error, reason} ->
{:error, "Order failed: #{inspect(reason)}"}
end
end
defp find_product(id) do
products = %{
101 => %{id: 101, name: "Elixir Book", price: 49.90, stock: 5},
102 => %{id: 102, name: "Erlang Book", price: 39.90, stock: 0}
}
case Map.fetch(products, id) do
{:ok, product} -> {:ok, product}
:error -> {:error, :not_found}
end
end
defp check_stock(%{stock: stock}, quantity) when stock >= quantity do
{:ok, stock}
end
defp check_stock(%{stock: stock}, _quantity) do
{:error, {:insufficient_stock, stock}}
end
defp create_order(user, product, _stock, quantity) do
order = %{
id: :rand.uniform(10000),
user_id: user.id,
product_id: product.id,
quantity: quantity,
total: product.price * quantity
}
{:ok, order}
end
end
Testing in IEx:
iex> OrderService.place_order(1, 101, 2)
{:ok,
%{
id: 5839,
user_id: 1,
product_id: 101,
quantity: 2,
total: 99.8
}}
iex> OrderService.place_order(99, 101, 2)
{:error, "User or product not found"}
iex> OrderService.place_order(2, 101, 2)
{:error, "User account is not active"}
iex> OrderService.place_order(1, 102, 1)
{:error, "Only 0 items in stock"}
Mixing Different Result Shapes
Sometimes you need to handle results from functions that don't return {:ok, _} / {:error, _}.
For example, a validator might return just :ok or {:error, _}. In these cases, you can tag the results in the with clauses:
defmodule MixedResultShapes do
def process_data(data) do
with {:validate, :ok} <- {:validate, validate_schema(data)},
{:save, {:ok, result}} <- {:save, save_to_db(data)} do
{:ok, result}
else
{:validate, {:error, errors}} -> {:error, {:validation_error, errors}}
{:save, {:error, reason}} -> {:error, {:save_error, reason}}
end
end
defp validate_schema(%{valid: true}), do: :ok
defp validate_schema(_), do: {:error, ["validation failed"]}
defp save_to_db(%{save_ok: true}), do: {:ok, %{saved: true}}
defp save_to_db(_), do: {:error, :db_error}
end
Testing in IEx:
iex> MixedResultShapes.process_data(%{valid: true, save_ok: true})
{:ok, %{saved: true}}
iex> MixedResultShapes.process_data(%{valid: false, save_ok: true})
{:error, {:validation_error, ["validation failed"]}}
iex> MixedResultShapes.process_data(%{valid: true, save_ok: false})
{:error, {:save_error, :db_error}}
# Direct tuple examples for reference:
iex> {:validate, :ok}
{:validate, :ok}
iex> {:validate, {:error, ["name is required"]}}
{:validate, {:error, ["name is required"]}}
iex> {:save, {:ok, %{name: "Alice"}}}
{:save, {:ok, %{name: "Alice"}}}
The Bang Convention
Functions with ! Suffix
In Elixir, functions with a ! suffix usually raise an exception on failure instead of returning {:error, reason}. I use this when I expect success and want failures to surface quickly.
defmodule FileProcessor do
# Returns {:ok, contents} or {:error, reason}
def read_file(path) do
File.read(path)
end
# Raises on failure
def read_file!(path) do
File.read!(path)
end
# Custom bang version
def find_user(id) do
case UserRepository.find(id) do
{:ok, user} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
end
def find_user!(id) do
case find_user(id) do
{:ok, user} -> user
{:error, :not_found} -> raise "User #{id} not found"
{:error, reason} -> raise "Failed to find user: #{inspect(reason)}"
end
end
end
Testing in IEx:
iex> FileProcessor.find_user(1)
{:ok, %{id: 1, name: "Alice", email: "alice@example.com", active: true}}
iex> FileProcessor.find_user(99)
{:error, :not_found}
iex> FileProcessor.find_user!(1)
%{id: 1, name: "Alice", email: "alice@example.com", active: true}
iex> FileProcessor.find_user!(99)
** (RuntimeError) User 99 not found
When to Use Each
I've found a simple rule helpful: I use the non-bang version when failure is an expected part of the business logic (for example, user not found or invalid input).
I use the bang version in initialization or setup code, where a failure means the program can't continue.
defmodule ConfigExample do
# Non-bang: failure is expected and handled by the caller
def find_config(key), do: Map.fetch(config(), key)
# Bang: used in startup, failure is catastrophic
def load_required_config!(key) do
case find_config(key) do
{:ok, value} -> value
:error -> raise "Required config key #{inspect(key)} is missing"
end
end
defp config do
%{app_name: "blog_elixir", version: "1.0.0"}
end
end
Testing in IEx:
iex> ConfigExample.find_config(:app_name)
{:ok, "blog_elixir"}
iex> ConfigExample.find_config(:missing_key)
:error
iex> ConfigExample.load_required_config!(:app_name)
"blog_elixir"
iex> ConfigExample.load_required_config!(:missing_key)
** (RuntimeError) Required config key :missing_key is missing
# Direct Map.fetch examples for reference:
iex> Map.fetch(%{app_name: "blog_elixir"}, :app_name)
{:ok, "blog_elixir"}
iex> Map.fetch(%{app_name: "blog_elixir"}, :missing)
:error
Practical Patterns
A Complete Service Layer
defmodule AccountService do
@min_password_length 8
def create_account(attrs) do
with {:ok, username} <- validate_username(attrs[:username]),
{:ok, email} <- UserValidator.validate_email(attrs[:email]),
{:ok, password} <- validate_password(attrs[:password]),
:ok <- check_username_availability(username),
:ok <- check_email_availability(email),
{:ok, account} <- persist_account(username, email, password) do
{:ok, account}
end
end
defp validate_username(username) when is_binary(username) do
trimmed = String.trim(username)
cond do
byte_size(trimmed) < 3 ->
{:error, {:username_too_short, min: 3}}
byte_size(trimmed) > 30 ->
{:error, {:username_too_long, max: 30}}
not String.match?(trimmed, ~r/^[a-zA-Z0-9_]+$/) ->
{:error, :username_invalid_characters}
true ->
{:ok, String.downcase(trimmed)}
end
end
defp validate_username(_other) do
{:error, :username_must_be_string}
end
defp validate_password(password) when is_binary(password) do
if byte_size(password) >= @min_password_length do
{:ok, hash_password(password)}
else
{:error, {:password_too_short, min: @min_password_length}}
end
end
defp validate_password(_other) do
{:error, :password_must_be_string}
end
defp check_username_availability(username) do
# Simulate checking database
taken_usernames = ["admin", "root", "elixir"]
if username in taken_usernames do
{:error, {:username_taken, username}}
else
:ok
end
end
defp check_email_availability(_email) do
# Simulate checking database
:ok
end
defp persist_account(username, email, password_hash) do
account = %{
id: :rand.uniform(100_000),
username: username,
email: email,
password_hash: password_hash,
created_at: DateTime.utc_now()
}
{:ok, account}
end
defp hash_password(password) do
# Simulation - in real code use Bcrypt or Argon2
:crypto.hash(:sha256, password) |> Base.encode64()
end
end
Testing in IEx:
iex> AccountService.create_account(%{username: "alice_dev", email: "alice@example.com", password: "securepass123"})
{:ok,
%{
id: 47291,
username: "alice_dev",
email: "alice@example.com",
password_hash: "...",
created_at: ~U[2026-03-05 10:00:00.000000Z]
}}
iex> AccountService.create_account(%{username: "admin", email: "admin@example.com", password: "securepass123"})
{:error, {:username_taken, "admin"}}
iex> AccountService.create_account(%{username: "ab", email: "short@example.com", password: "securepass123"})
{:error, {:username_too_short, [min: 3]}}
iex> AccountService.create_account(%{username: "valid_user", email: "valid@example.com", password: "short"})
{:error, {:password_too_short, [min: 8]}}
Collecting Multiple Errors
Sometimes you want to validate all fields and return all errors at once instead of stopping at the first failure:
defmodule FormValidator do
def validate_registration_form(params) do
errors =
[]
|> validate_field(:name, params[:name], &UserValidator.validate_name/1)
|> validate_field(:email, params[:email], &UserValidator.validate_email/1)
|> validate_field(:age, params[:age], &UserValidator.validate_age/1)
if Enum.empty?(errors) do
{:ok, sanitize_params(params)}
else
{:error, {:validation_failed, errors}}
end
end
defp validate_field(errors, field, value, validator) do
case validator.(value) do
{:ok, _valid_value} -> errors
{:error, reason} -> [{field, reason} | errors]
end
end
defp sanitize_params(params) do
params
|> Map.update(:name, nil, &String.trim/1)
|> Map.update(:email, nil, &String.downcase/1)
end
end
Testing in IEx:
iex> FormValidator.validate_registration_form(%{name: "Alice", email: "alice@example.com", age: 25})
{:ok, %{name: "Alice", email: "alice@example.com", age: 25}}
iex> FormValidator.validate_registration_form(%{name: "", email: "not-an-email", age: -5})
{:error,
{:validation_failed,
[
age: {:age_out_of_range, -5},
email: {:invalid_email, "not-an-email"},
name: :name_must_be_string
]}}
Working with Lists of Results
defmodule BatchProcessor do
def process_all(items) do
results = Enum.map(items, &process_one/1)
case Enum.split_with(results, fn
{:ok, _} -> true
{:error, _} -> false
_other -> false
end) do
{successes, []} ->
values = Enum.map(successes, fn {:ok, v} -> v end)
{:ok, values}
{successes, failures} ->
errors =
Enum.map(failures, fn
{:error, reason} -> reason
other -> {:unexpected_result, other}
end)
{:partial, Enum.map(successes, fn {:ok, v} -> v end), errors}
end
end
# Fails fast: stops at the first error
def process_all_strict(items) do
Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} ->
case process_one(item) do
{:ok, result} -> {:cont, {:ok, [result | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, results} -> {:ok, Enum.reverse(results)}
error -> error
end
end
defp process_one(item) when is_integer(item) and item > 0 do
{:ok, item * 2}
end
defp process_one(item) do
{:error, {:invalid_item, item}}
end
end
Testing in IEx:
iex> BatchProcessor.process_all([1, 2, 3, 4, 5])
{:ok, [2, 4, 6, 8, 10]}
iex> BatchProcessor.process_all([1, -2, 3, -4, 5])
{:partial, [2, 6, 10], [{:invalid_item, -2}, {:invalid_item, -4}]}
iex> BatchProcessor.process_all_strict([1, 2, 3, 4, 5])
{:ok, [2, 4, 6, 8, 10]}
iex> BatchProcessor.process_all_strict([1, -2, 3])
{:error, {:invalid_item, -2}}
Best Practices
I return tagged tuples from functions that can fail — this keeps success and failure visible in the return value instead of hidden behind nil or exceptions.
I try to make error reasons descriptive — specific, actionable reasons are easier to debug:
For example, I find {:error, {:missing_field, :email}} much easier to debug than {:error, :bad_input}.
I use with for sequential operations — it gives me a clean pipeline with a single error handling point.
I avoid silently discarding errors — if I assign _ = Repository.insert(user) and ignore the result, I hide failures from myself. I try to propagate or log them.
I try to stay consistent — tagged tuples as default keep code easier to reason about; mixing nil and {:error, _} can make behavior harder to predict.
Conclusion
The {:ok, value} and {:error, reason} pattern became one of the most useful conventions for me in Elixir. Writing and testing these examples made me more confident with failure paths.
Some things I learned:
-
Errors are values: I get better results when I treat
{:error, reason}like any other return value - Descriptive reasons help a lot: Clear error reasons make debugging faster for me
-
withkeeps pipelines readable: I can chain steps and handle failures in one place - Consistency pays off: Using one style across the codebase makes behavior easier to predict
-
Bang functions fit programming errors: I use
!variants when failure means my assumptions were wrong - I try not to discard errors: Propagating or logging failures saves debugging time later
Learning this pattern made my Elixir code easier to reason about, test, and maintain. I still keep refining it, but it already makes day-to-day coding much clearer.
Further Reading
- Elixir Official Documentation - case, cond, and if
- Elixir Official Documentation - with
- Elixir School - Error Handling
- Programming Elixir by Dave Thomas
Next Steps
After learning the {:ok, value} / {:error, reason} pattern, I felt ready to explore Try, Catch, and Rescue in Elixir. Tagged tuples handle expected failures, while exceptions help with unexpected situations.
In the next article, we'll explore:
- When to use
try/rescuevs tagged tuples - Defining and raising custom exceptions
- Using
catchfor exits and throws - The
afterclause for cleanup and resource management - Practical guidelines for combining both error handling approaches
Understanding exceptions rounds out the error handling toolkit. It helped me separate expected validation errors from truly unexpected runtime failures.
Top comments (0)