DEV Community

Cover image for Learning Elixir: Error Handling with try/rescue/catch/after
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Error Handling with try/rescue/catch/after

I like to think of error handling in Elixir as having two different toolkits for two different kinds of problems. Tagged tuples — {:ok, value} and {:error, reason} — are like a polite conversation where both sides agree on what could go wrong ahead of time. But sometimes the unexpected happens: a process crashes, a library raises an exception, or you need to abort a deep computation with a throw. That is where try, catch, rescue, and after come in. They are like a safety net under a trapeze artist — you do not plan to fall, but you are glad the net is there when something goes wrong. In this article, I will explore when and how to use these constructs, and what I learned about keeping error handling intentional and readable.

Note: The examples in this article use Elixir 1.20.1. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

In our previous article about error handling basics, I explored the {:ok, value} and {:error, reason} pattern. That approach works wonderfully for predictable failures: a user not found, invalid input, a missing configuration key. But not all failures are predictable. Sometimes a library function raises an exception, an arithmetic operation divides by zero, or a remote call crashes the calling process. For those situations, Elixir provides try, rescue, catch, and after.

What I learned about these constructs:

  • Rescue catches raised exceptions — things like RuntimeError, ArgumentError, or custom exceptions
  • Catch handles throws and exits — different mechanisms for non-local control flow
  • After runs cleanup code — guaranteed to execute whether or not an error occurred
  • They complement tagged tuples — they do not replace them, but handle a different category of problems
  • Elixir discourages overuse — the philosophy is "let it crash" in many cases, so these tools are used sparingly

I found that understanding when not to use these constructs is just as important as knowing how they work.

When to Use Try/Rescue vs Tagged Tuples

The Core Distinction

I like to think of the difference this way: tagged tuples are for expected problems, while try/rescue is for unexpected problems that you cannot avoid.

defmodule Conversions do
  # Expected problem: user provides invalid input
  # Solution: return {:error, reason}
  def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
    {:ok, a / b}
  end

  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(_a, _b), do: {:error, :invalid_input}

  # Unexpected problem: library raises an exception
  # Solution: use try/rescue
  def safe_parse_integer(string) do
    try do
      {:ok, String.to_integer(string)}
    rescue
      ArgumentError -> {:error, :not_an_integer}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Conversions.divide(10, 2)
{:ok, 5.0}

iex> Conversions.divide(10, 0)
{:error, :division_by_zero}

iex> Conversions.safe_parse_integer("42")
{:ok, 42}

iex> Conversions.safe_parse_integer("not_a_number")
{:error, :not_an_integer}
Enter fullscreen mode Exit fullscreen mode

When I Prefer Tagged Tuples

defmodule FileReader do
  # Better: explicit return values
  def read_config(path) do
    case File.read(path) do
      {:ok, contents} -> parse_config(contents)
      {:error, reason} -> {:error, {:file_read_failed, reason}}
    end
  end

  defp parse_config(contents) do
    # Parsing logic here
    {:ok, contents}
  end
end
Enter fullscreen mode Exit fullscreen mode

When I Use Try/Rescue

defmodule SafeCalculator do
  # Necessary: division raises an exception when divisor is zero
  def safe_div(a, b) do
    try do
      {:ok, a / b}
    rescue
      ArithmeticError -> {:error, :division_by_zero}
    end
  end

  # Another common case: parsing that may raise
  def parse_positive_integer(string) do
    try do
      value = String.to_integer(string)
      if value > 0, do: {:ok, value}, else: {:error, :not_positive}
    rescue
      ArgumentError -> {:error, :not_an_integer}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> SafeCalculator.safe_div(10, 2)
{:ok, 5.0}

iex> SafeCalculator.safe_div(10, 0)
{:error, :division_by_zero}

iex> SafeCalculator.parse_positive_integer("42")
{:ok, 42}

iex> SafeCalculator.parse_positive_integer("abc")
{:error, :not_an_integer}
Enter fullscreen mode Exit fullscreen mode

Understanding Exceptions in Elixir

Built-in Exception Types

Elixir has several built-in exception types that you might encounter:

# RuntimeError - general purpose exception
raise "Something went wrong"
# ** (RuntimeError) Something went wrong

# ArgumentError - wrong argument type or value
raise ArgumentError, message: "expected a positive integer"
# ** (ArgumentError) expected a positive integer

# ArithmeticError - math operations that fail
1 / 0
# ** (ArithmeticError) bad argument in arithmetic expression

# FunctionClauseError - no matching function clause
String.upcase(123)
# ** (FunctionClauseError) no function clause matching in String.upcase/2

# MatchError - pattern matching failed
{:ok, value} = {:error, :not_found}
# ** (MatchError) no match of right hand side value: {:error, :not_found}
Enter fullscreen mode Exit fullscreen mode

To see the messages without crashing the IEx session, I wrapped them in small functions:

defmodule ExceptionExamples do
  def runtime_message do
    try do
      raise "Something went wrong"
    rescue
      e in RuntimeError -> e.message
    end
  end

  def arithmetic_message do
    try do
      divide_by_zero()
    rescue
      e in ArithmeticError -> "Math error: #{e.message}"
    end
  end

  # Written this way so the compiler does not evaluate the division ahead of time
  defp divide_by_zero do
    zero = 0
    1 / zero
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ExceptionExamples.runtime_message()
"Something went wrong"

iex> ExceptionExamples.arithmetic_message()
"Math error: bad argument in arithmetic expression"
Enter fullscreen mode Exit fullscreen mode

How Exceptions Work

I learned that exceptions in Elixir are not like exceptions in many other languages. They are used for truly exceptional circumstances, not for regular control flow. The philosophy is:

  1. Let it crash — if a process encounters an error, let it fail and be restarted by a supervisor
  2. Explicit over implicit — prefer returning error tuples over raising exceptions
  3. Exceptions are a last resort — use them when you have no other choice
defmodule ExceptionDemo do
  # This raises an exception - we would not normally write code like this
  def will_crash do
    raise "This is an error"
  end

  # Better: return an error tuple
  def will_return_error do
    {:error, "This is an error"}
  end

  # Wrapping the crash in try/rescue
  def crash_safely do
    try do
      will_crash()
    rescue
      RuntimeError -> "Caught it!"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ExceptionDemo.will_return_error()
{:error, "This is an error"}

iex> ExceptionDemo.crash_safely()
"Caught it!"
Enter fullscreen mode Exit fullscreen mode

Rescuing Exceptions with try/rescue

Basic Rescue Pattern

The try/rescue block catches exceptions that occur within the try block:

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

  def parse_date(string) do
    try do
      {:ok, Date.from_iso8601!(string)}
    rescue
      ArgumentError -> {:error, :invalid_date_format}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Rescuer.safe_divide(10, 2)
{:ok, 5.0}

iex> Rescuer.safe_divide(10, 0)
{:error, :division_by_zero}

iex> Rescuer.parse_date("2024-03-15")
{:ok, ~D[2024-03-15]}

iex> Rescuer.parse_date("not-a-date")
{:error, :invalid_date_format}
Enter fullscreen mode Exit fullscreen mode

Rescuing Multiple Exception Types

You can rescue different exception types with different handlers:

defmodule MultiRescuer do
  def process_file(path) do
    try do
      contents = File.read!(path)
      data = String.to_integer(String.trim(contents))
      {:ok, data}
    rescue
      File.Error ->
        {:error, :file_not_found}

      ArgumentError ->
        {:error, :invalid_number}

      e in RuntimeError ->
        {:error, {:runtime_error, e.message}}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> MultiRescuer.process_file("nonexistent.json")
{:error, :file_not_found}
Enter fullscreen mode Exit fullscreen mode

Accessing the Exception

When you rescue an exception, you can bind it to a variable and inspect its fields:

defmodule ExceptionInspector do
  def inspect_error(operation) do
    try do
      operation.()
    rescue
      e in ArgumentError ->
        {:argument_error, e.message}

      e in RuntimeError ->
        {:runtime_error, e.message}

      e ->
        {:unknown_error, Exception.message(e)}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ExceptionInspector.inspect_error(fn -> raise ArgumentError, "bad arg" end)
{:argument_error, "bad arg"}

iex> ExceptionInspector.inspect_error(fn -> raise "Something failed" end)
{:runtime_error, "Something failed"}
Enter fullscreen mode Exit fullscreen mode

Stack Traces

You can also access the stack trace when rescuing:

defmodule StackTraceDemo do
  def boom do
    raise "Oops"
  end

  def log_error(operation) do
    try do
      operation.()
    rescue
      e ->
        [{module, function, arity, _location} | _rest] = __STACKTRACE__
        IO.puts("Exception: #{Exception.message(e)}")
        IO.puts("Raised in: #{inspect(module)}.#{function}/#{arity}")
        {:error, :logged}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> StackTraceDemo.log_error(&StackTraceDemo.boom/0)
Exception: Oops
Raised in: StackTraceDemo.boom/0
{:error, :logged}
Enter fullscreen mode Exit fullscreen mode

Important: __STACKTRACE__ is only available inside rescue, catch, and else blocks. It returns the stack trace for the current exception.

Defining and Raising Custom Exceptions

Creating Custom Exceptions

Elixir allows you to define your own exception types using defexception. This is useful when you want to signal specific error conditions in your application:

defmodule PaymentError do
  defexception [:message, :code, :transaction_id]

  @impl true
  def exception(attrs) do
    message = attrs[:message] || "Payment failed"
    code = attrs[:code] || :unknown
    transaction_id = attrs[:transaction_id]

    %PaymentError{message: message, code: code, transaction_id: transaction_id}
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> PaymentError.exception(message: "Insufficient funds", code: :insufficient_funds)
%PaymentError{message: "Insufficient funds", code: :insufficient_funds, transaction_id: nil}

iex> raise PaymentError, message: "Card declined", code: :card_declined
** (PaymentError) Card declined
Enter fullscreen mode Exit fullscreen mode

Simpler Custom Exceptions

For many cases, a simpler definition is sufficient:

defmodule ValidationError do
  defexception [:field, :message]
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> raise ValidationError, field: :email, message: "is required"
** (ValidationError) is required
Enter fullscreen mode Exit fullscreen mode

Using Custom Exceptions in Practice

defmodule PaymentProcessor do
  def charge(card_number, amount) do
    with {:ok, validated_card} <- validate_card(card_number),
         {:ok, _sufficient} <- check_balance(validated_card, amount) do
      do_charge(validated_card, amount)
    end
  end

  defp validate_card(card_number) do
    if String.length(card_number) == 16 do
      {:ok, card_number}
    else
      raise ValidationError,
        field: :card_number,
        message: "must be 16 digits, got #{String.length(card_number)}"
    end
  end

  defp check_balance(_card, amount) when amount > 0 do
    {:ok, true}
  end

  defp check_balance(_card, amount) do
    raise PaymentError,
      message: "Amount must be positive, got #{amount}",
      code: :invalid_amount
  end

  defp do_charge(card, amount) do
    # Simulate successful charge with a deterministic fake ID
    transaction_id = "txn_#{amount}_#{String.slice(card, -4, 4)}"
    {:ok, %{transaction_id: transaction_id, card: card, amount: amount}}
  end

  # Wraps charge/2 and converts exceptions into tagged tuples
  def charge_safely(card_number, amount) do
    try do
      charge(card_number, amount)
    rescue
      e in ValidationError -> {:validation_failed, e.field, e.message}
      e in PaymentError -> {:payment_failed, e.code, e.message}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> PaymentProcessor.charge_safely("1234567890123456", 100)
{:ok, %{amount: 100, card: "1234567890123456", transaction_id: "txn_100_3456"}}

iex> PaymentProcessor.charge_safely("1234", 100)
{:validation_failed, :card_number, "must be 16 digits, got 4"}

iex> PaymentProcessor.charge_safely("1234567890123456", -50)
{:payment_failed, :invalid_amount, "Amount must be positive, got -50"}
Enter fullscreen mode Exit fullscreen mode

Note: In real code, I would probably return {:error, reason} for validation failures instead of raising. I show this example to demonstrate how custom exceptions work when you need them.

Using catch for Exits and Throws

The Difference Between rescue and catch

I learned that rescue and catch handle different things:

  • rescue catches exceptions — errors that represent something going wrong
  • catch catches throws and exits — different mechanisms for non-local control flow

Catching Throws

throw is a way to escape from a deep computation quickly. It is not an error — it is a control flow mechanism:

defmodule ThrowDemo do
  def find_first_match(items, predicate) do
    try do
      Enum.each(items, fn item ->
        if predicate.(item) do
          throw({:found, item})
        end
      end)

      :not_found
    catch
      {:found, item} -> {:ok, item}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ThrowDemo.find_first_match([1, 2, 3, 4, 5], fn x -> x > 3 end)
{:ok, 4}

iex> ThrowDemo.find_first_match([1, 2, 3], fn x -> x > 10 end)
:not_found
Enter fullscreen mode Exit fullscreen mode

Important: Using throw for control flow is generally discouraged in Elixir. I find that in most cases, Enum.find/2 or restructuring the code with explicit returns is cleaner. I show this for completeness, but I try to avoid throw in my own code.

Catching Exits

The catch clause also handles exits raised with exit/1 inside the try block. An exit is another form of non-local control flow — the code asks the current process to terminate with a reason:

defmodule ExitDemo do
  def abort_with_reason(action) do
    try do
      case action do
        :stop -> exit(:stopped_early)
        :fail -> exit({:failed, :bad_input})
        :ok -> :completed
      end
    catch
      :exit, reason -> {:caught_exit, reason}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ExitDemo.abort_with_reason(:ok)
:completed

iex> ExitDemo.abort_with_reason(:stop)
{:caught_exit, :stopped_early}

iex> ExitDemo.abort_with_reason(:fail)
{:caught_exit, {:failed, :bad_input}}
Enter fullscreen mode Exit fullscreen mode

Important: catch only catches exits raised inside the try block itself. It does not catch exit signals sent by other processes. When a linked process dies, a process that is not trapping exits simply dies too — the catch clause never runs. I learned this the hard way while testing these examples in IEx.

To react when another process dies, the pattern is different: trap exits so signals arrive as regular {:EXIT, pid, reason} messages:

defmodule ExitSignalDemo do
  def watch_linked_process do
    Process.flag(:trap_exit, true)
    spawn_link(fn -> exit(:abnormal) end)

    receive do
      {:EXIT, _pid, reason} -> {:process_exited, reason}
    after
      1_000 -> :no_exit_received
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ExitSignalDemo.watch_linked_process()
{:process_exited, :abnormal}
Enter fullscreen mode Exit fullscreen mode

Note: Catching exits and trapping exit signals are both rare in normal application code. The "let it crash" philosophy means we usually let supervisors handle failing processes instead of catching exits ourselves. I show these patterns because they appear in library code, and understanding them helped me read process-related code with more confidence.

Catch Syntax

The catch clause has a specific syntax:

try do
  # code that might throw or exit
rescue
  # catches exceptions
catch
  :throw, value ->
    # catches throw(value)
    {:thrown, value}

  :exit, reason ->
    # catches exit(reason)
    {:exited, reason}

  kind, value ->
    # catches any kind (throw or exit)
    {:caught, kind, value}
end
Enter fullscreen mode Exit fullscreen mode

The after Clause for Cleanup

Guaranteed Cleanup

The after clause is one of my favorite parts of try. It guarantees that code runs whether or not an exception occurred. I think of it as a "finally" block, but with Elixir's elegant syntax:

defmodule FileHandler do
  def process_file(path, processor) do
    file = File.open!(path, [:read])

    try do
      contents = IO.read(file, :eof)
      processor.(contents)
    after
      File.close(file)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> File.write!("/tmp/demo.txt", "Hello from Elixir")
:ok

iex> FileHandler.process_file("/tmp/demo.txt", &String.trim/1)
"Hello from Elixir"
Enter fullscreen mode Exit fullscreen mode

Note: In modern Elixir, File.open/2 with a function callback already handles cleanup, so this pattern is less common. I show it to illustrate how after works.

After with Return Values

The after clause does not affect the return value of the try block:

defmodule AfterDemo do
  def with_cleanup do
    try do
      :success
    after
      IO.puts("Cleanup runs!")
    end
  end

  def with_error_cleanup do
    try do
      raise "Error!"
    after
      IO.puts("Cleanup still runs!")
    end
  end

  # Wrapping the error in try/rescue
  def with_rescued_error do
    try do
      with_error_cleanup()
    rescue
      _ -> "Rescued after cleanup"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> AfterDemo.with_cleanup()
Cleanup runs!
:success

iex> AfterDemo.with_rescued_error()
Cleanup still runs!
"Rescued after cleanup"
Enter fullscreen mode Exit fullscreen mode

Common Cleanup Patterns

defmodule ResourceManager do
  def with_temp_file(contents, callback) do
    path = "/tmp/elixir_temp_#{:rand.uniform(100000)}.txt"

    try do
      File.write!(path, contents)
      callback.(path)
    after
      File.rm(path)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ResourceManager.with_temp_file("temporary contents", fn path -> File.read!(path) end)
"temporary contents"
Enter fullscreen mode Exit fullscreen mode

Combining Rescue, Catch, and After

Complete try Block

You can combine all the clauses in a single try block:

defmodule CompleteHandler do
  def handle_operation(operation) do
    try do
      result = operation.()
      {:ok, result}
    rescue
      e in ArithmeticError ->
        {:arithmetic_error, e.message}

      e in ArgumentError ->
        {:argument_error, e.message}
    catch
      :throw, value ->
        {:thrown, value}

      :exit, reason ->
        {:exited, reason}
    after
      IO.puts("Operation completed (success or failure)")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> CompleteHandler.handle_operation(fn -> 42 end)
Operation completed (success or failure)
{:ok, 42}

iex> CompleteHandler.handle_operation(fn -> throw(:early_exit) end)
Operation completed (success or failure)
{:thrown, :early_exit}

iex> CompleteHandler.handle_operation(fn -> 1 / 0 end)
Operation completed (success or failure)
{:arithmetic_error, "bad argument in arithmetic expression"}
Enter fullscreen mode Exit fullscreen mode

Order of Clauses

The order matters: rescue is checked first, then catch, and after always runs last — whether the operation succeeded or failed.

Using else with try

A try block can also have an else clause that pattern matches on the result when no exception occurs, similar to the else in with. I almost never use it — I find it clearer to handle the success value directly in the try body or with case afterwards — so I mention it here only so you recognize it when reading other people's code.

Practical Guidelines

What I Learned About Best Practices

I prefer tagged tuples for expected errors — if I can predict that something might fail, I return {:error, reason}. This makes the error path explicit in the function's return value.

I use try/rescue when calling external code — libraries, parsing functions, or OS operations might raise exceptions that I cannot prevent. Wrapping them in try/rescue and converting to tagged tuples creates a consistent interface.

I avoid rescue for control flow — using exceptions for regular logic makes code harder to follow. I reserve them for truly exceptional circumstances.

I use after for resource cleanup — whether opening files, starting transactions, or acquiring locks, after ensures cleanup happens.

I let processes crash when appropriate — in OTP applications, supervisors handle process failures. I do not wrap every call in try/rescue because that defeats the fault-tolerance model.

defmodule Guidelines do
  # Good: explicit error handling
  def divide_explicit(a, b) when b != 0 do
    {:ok, div(a, b)}
  end

  def divide_explicit(_a, 0) do
    {:error, :division_by_zero}
  end

  # Good: wrapping parsing that may raise
  def parse_integer_safe(string) do
    try do
      {:ok, String.to_integer(string)}
    rescue
      ArgumentError -> {:error, :not_an_integer}
    end
  end

  # Good: cleanup with after
  def with_resource(resource, callback) do
    try do
      callback.(resource)
    after
      release(resource)
    end
  end

  defp release(_resource), do: :ok

  # Avoid: using exceptions for control flow
  # def find_user_bad(id) do
  #   try do
  #     user = Repo.get!(User, id)
  #     {:ok, user}
  #   rescue
  #     Ecto.NoResultsError -> {:error, :not_found}
  #   end
  # end

  # Better: use the non-bang version
  def find_user_good(id) do
    case repo().get.(:user, id) do
      nil -> {:error, :not_found}
      user -> {:ok, user}
    end
  end

  defp repo do
    # Mock for demonstration
    %{get: fn _schema, id ->
      if id == 1, do: %{id: 1, name: "Alice"}, else: nil
    end}
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Guidelines.divide_explicit(10, 2)
{:ok, 5}

iex> Guidelines.divide_explicit(10, 0)
{:error, :division_by_zero}

iex> Guidelines.find_user_good(1)
{:ok, %{id: 1, name: "Alice"}}

iex> Guidelines.find_user_good(99)
{:error, :not_found}
Enter fullscreen mode Exit fullscreen mode

Converting Exceptions to Tagged Tuples

I find this pattern very useful — a wrapper function that converts exceptions into the {:ok, _} / {:error, _} pattern:

defmodule SafeWrapper do
  def wrap_exception(fun) do
    try do
      {:ok, fun.()}
    rescue
      e -> {:error, Exception.message(e)}
    end
  end

  def wrap_with_type(fun) do
    try do
      {:ok, fun.()}
    rescue
      e in ArgumentError -> {:error, {:argument_error, e.message}}
      e in RuntimeError -> {:error, {:runtime_error, e.message}}
      e -> {:error, {:unknown_error, Exception.message(e)}}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> SafeWrapper.wrap_exception(fn -> String.to_integer("42") end)
{:ok, 42}

iex> SafeWrapper.wrap_exception(fn -> Date.from_iso8601!("not-a-date") end)
{:error, "cannot parse \"not-a-date\" as date, reason: :invalid_format"}

iex> SafeWrapper.wrap_with_type(fn -> raise ArgumentError, "bad" end)
{:error, {:argument_error, "bad"}}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Learning about try, rescue, catch, and after helped me understand how Elixir handles the unexpected. While the {:ok, value} / {:error, reason} pattern covers most day-to-day error handling, these constructs provide a safety net for situations where exceptions are unavoidable.

Some things I learned:

  • Tagged tuples are my default — I reach for {:ok, _} and {:error, _} first because they make error paths explicit
  • Try/rescue wraps the unpredictable — I use it mainly when calling external libraries or system operations that might raise
  • Custom exceptions add clarity — defining defexception helps communicate specific error conditions in domain terms
  • Catch handles throws and exits — I rarely use throw for control flow, but understanding catch helps me read library code
  • After guarantees cleanup — the after clause ensures resources are released, connections closed, and state cleaned up
  • Let it crash still applies — in OTP applications, supervisors often handle failures better than manual rescue blocks

The key insight for me was that these tools are complementary, not competing. Tagged tuples handle expected problems. Try/rescue handles the unexpected. Using both wisely makes code both explicit and resilient.

Further Reading

Next Steps

With error handling patterns in place, the next step is learning how to organize code into reusable, named units. Creating Modules in Elixir is the foundation of every Elixir application, from small scripts to large distributed systems.

In the next article, we will explore:

  • Defining modules with defmodule and understanding their structure
  • Creating public and private functions with def and defp
  • Using module attributes for metadata and constants
  • How modules map to files and directories in a project
  • Building a small module from scratch to tie the concepts together

Modules are where everything comes together — functions, error handling, and data structures — into cohesive, maintainable units of code.

Top comments (0)