23 Jul 2026

About 2 years ago, I was trying to hone my knowledge on Ruby. One of the things that always bothered me was when and when not to raise an exception. That's when I read the book Exceptional Ruby by Avdi Grimm.

It was a short and interesting book which showed me many ways of handling errors both with and without raising exceptions. But there is one particular quote in the book which completely changed my way of raising exceptions:

Exceptions shouldn’t be expected. Use exceptions only for exceptional situations.

This statement might not make sense at first, but it can help you write clean code once you understand it.

Basically, if a code flow can be rewritten without using an exception, then we really don't need an exception there. I'm not talking about removing exceptions but handling failure without raising them. Exceptions are still needed to alert us to exceptional situations like database connection drops, memory exhaustion, or network timeouts. But for scenarios like an unauthorized user or missing record, we can definitely handle them without exceptions.

Consider this example:

reader.rb
class Reader
  def read(book)
    fetch_content!(book)
  rescue UnauthorizedError => e
    puts e.message
  end

private

  def fetch_content!(book)
    raise UnauthorizedError, "Not authorized" unless allowed_access?(book)

    access_content(book)
  end
end

Here, we are passing a Book object to a Reader object to read it. But before that, we are checking if the reader has access to that book.

Considering both raise and rescue happen in the same file, it can be easily rewritten like this:

reader.rb
class Reader
  def read(book)
    result = fetch_content!(book)
    if result == :unauthorized
      puts "Not authorized"
    else
      result
    end
  end

private

  def fetch_content!(book)
    return :unauthorized unless allowed_access?(book)

    access_content(book)
  end
end

See, we didn't need that exception! Well, a single-file example isn't much, but it was the simplest scenario I could immediately think of. Still, it can apply to some scenarios where an exception is raised in one file specifically to be rescued in another file. It can be very easy to misuse exceptions.

Coming back to my favorite quote from the book, in our example code we are actually expecting the book to be unauthorized and so we can and should handle that expectation without an exception.

I admit that my second example is a bit odd because it doesn't follow standard day-to-day conventions. In the success flow, we are returning a String and in the failure flow, we are returning a Symbol. Ruby is a dynamically typed language, but that doesn't mean we should be careless with return types.

So what if we returned a String in both scenarios? Then another followup question would arise like: what if the content of the book itself is 'unauthorized'?.

The second example isn't a proper way to handle a failure scenario, but it is better than using an unwanted exception.

So, how do we solve this without using an exception while differentiating the success flow from the failure flow?

The book Exceptional Ruby suggests several ways to do this. But one particular way stuck with me because in the following years, I saw it has been implemented as a default convention in two different languages I was trying to learn: Rust and Elixir

That way is Railway Oriented Programming.

Railway Oriented Programming (ROP) is a functional design pattern that treats code like a railway track to handle errors cleanly. Think of your happy path as the main track. If a step fails, the execution switches tracks to an error bypass, allowing subsequent steps to safely handle or skip operations without crashing the program.

A function that uses ROP always returns its result wrapped in an 'envelope'. The envelope object/structure usually contains two things:

  1. A status indicating success or failure
  2. The result of function call in case of success or the error details in case of failure

Let's see how the same example can be written in Rust and Elixir using their own conventions:

Rust:

reader.rs
pub struct Reader;

impl Reader {
    pub fn read(&self, book: &Book) -> Option<String> {
        match self.fetch_content(book) {
            Ok(content) => Some(content),
            Err(ReaderError::Unauthorized) => {
                eprintln!("Not authorized");
                None
            }
        }
    }

    fn fetch_content(&self, book: &Book) -> Result<String, ReaderError> {
        if self.allowed_access(book) {
            Ok(self.access_content(book))
        } else {
            Err(ReaderError::Unauthorized)
        }
    }
}

Elixir:

reader.ex
defmodule Reader do
  require Logger

  def read(book) do
    case fetch_content(book) do
      {:ok, content} ->
        content
      {:error, :unauthorized} ->
        Logger.error("Not authorized")
    end
  end

  defp fetch_content(book) do
    if allowed_access?(book) do
      {:ok, access_content(book)}
    else
      {:error, :unauthorized}
    end
  end
end

Look at how elegantly they are handling the error scenarios.

Rust uses a built-in Result enum to implement ROP. Its base definition is like this:

result.rs
enum Result<T, E> {
   Ok(T),
   Err(E),
}

Since Rust is a statically typed language, you have to specify the data type you are expecting in both success and failure scenarios when you are returning a Result data type. (look at the return type of fetch_content function in the Rust example)

When you are returning the values from the function, you can wrap the value in Ok() to state the success scenario or in Err() to state the failure scenario. The caller will also be expecting both of them and use pattern matching to change the code flow (or track) accordingly.

Elixir however does not use any special data type or structure. It simply returns a tuple where the first element is an atom data type (usually either :ok or :error) and the second element is the result of the function call.

Neither languages forces ROP and you can return any other normal data type if you want. But in both of the languages, it is widely adopted as a default convention, which is why I wanted to adapt it in Ruby also.

In Ruby, we can implement ROP easily by returning an Array with two elements. Here's the second example modified with ROP:

reader.rb
class Reader
  def read(book)
    case fetch_content(book)
    in :ok, result
      result
    in :error, error
      puts error
    end
  end

private

  def fetch_content(book)
    return [:error, :unauthorized] unless allowed_access?(book)

    [:ok, access_content(book)]
  end
end
note

The case...in syntax is an advanced pattern-matching feature that was introduced experimentally in Ruby 2.7 and officially finalized in Ruby 3.0.

Just like that, we handled our problem with ROP elegantly in Ruby. If you want a separate defined type like Result in Rust, we can also do that with simple code as well:

result.rb
class Result
  extend Forwardable
  delegate :[] => :result

  def initialize(status, object = nil)
    raise ArgumentError unless status == :ok || status == :error

    @result = [ status, object ]
  end

  def ok?
    result[0] == :ok
  end

  def error?
    result[0] == :error
  end

  # for handling success scenario
  def with_ok(&block)
    return self unless result[0] == :ok

    yield result[1]
  end

  # for handling error scenario
  def with_error(&block)
    return self unless result[0] == :error

    yield result[1]
  end

  # for pattern matching
  def deconstruct
    result
  end

  # for parallel assignment
  def to_ary
    result
  end

  private

  attr_reader :result
end

Like I said, there are other interesting patterns in the book to handle the failure case without raising an exception. But I wanted to talk specifically about ROP because of its elegance and wide adoption.

With a dedicated class (like the one in the last example), we can structure our entire application based on ROP and can also easily maintain it as a convention for returning values. Though it is more of a functional programming pattern, we can adapt it to an OOP Language like Ruby by using them in between method calls. Because at the end of the day, Object-Oriented Programming is ultimately about passing messages between objects.