DEV Community

Cover image for Learning Elixir: Error Handling Basics
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Error Handling Basics

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

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]}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  ]}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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
  • with keeps 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

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/rescue vs tagged tuples
  • Defining and raising custom exceptions
  • Using catch for exits and throws
  • The after clause 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)