Ruby to the Rescue

Taylor Kearns - Jan 30 '23 - - Dev Community

TL;DR

Though we frequently use the terms "throw" and "catch" when discussing error handling, we must be careful in Ruby to use the more accurate terms "raise" and "rescue". "Throw" and "catch" have a significantly different meaning in Ruby.

The Problem

The other day we were writing an RSpec test and were surprised to find that we couldn't get this test to pass:

expect { throw StandardError.new("error") }.to raise_error

But we could get this test to pass:

expect { raise StandardError.new("error") }.to raise_error

Aren't these basically the same thing, with a minor syntactic difference? Raise an error, throw an error, same idea right? In Javascript we use try..catch for handling errors. Isn't it the same idea with Ruby? Not really. Ruby has two similar looking statements: try..catch and raise..rescue. Even though we can technically use each of these statements in similar situations and get ostensibly similar results, the intent behind the two structures is quite different.

In Ruby, Don't Catch Errors; Rescue Them

The main source of confusion (for me, anyway) is in the term catch. When I think catch, I think error. In Javascript if we want to handle an error we write something like:

try {
  myFunction()
catch (error) {
  console.error(`There was an error: ${error}`)
}
Enter fullscreen mode Exit fullscreen mode

So in Ruby wouldn't I do something similar? Yes, but not with try..catch. In Ruby we use raise..rescue.

def my_function
  do_the_work
rescue => error
  logger.error("There was an error: #{error}")
end
Enter fullscreen mode Exit fullscreen mode

Ruby's raise..rescue is the corollary to Javascript's try..catch. If we compare the complete syntax of both structures, especially when we employ the optional begin keyword, the patterns look even more similar:

Javascript

try {
  // do stuff
catch {
  // catch the error
finally {
  // do this no matter what
}
Enter fullscreen mode Exit fullscreen mode

Ruby

begin
  # do stuff
rescue => e
  # catch the error
ensure
  # do this no matter what
end
Enter fullscreen mode Exit fullscreen mode

So what is catch for in Ruby, if not for errors?

The throw..catch statement in Ruby is actually intended to be used as a part of an expected workflow rather than for an exceptional (i.e. error) scenario. throw..catch can be used as a means of escaping from a flow once a particular requirement is met. In the example below, we have an array of birds. As soon as we find a goose, we want to stop execution of the each loop. This example is contrived, but we can imagine that if we were performing expensive procedures in each iteration, we would want to bail out as soon as we are able.

birds = ["duck", "duck", "goose", "duck"]

catch(:goose) do
  birds.each do |bird|
    puts bird

    if bird == "goose"
      throw(:goose)
    end
  end
end

$> duck
$> duck
$> goose
Enter fullscreen mode Exit fullscreen mode

catch is meant to be used with throw, and likewise throw is meant to be used with catch. Though we may see code in the wild in which an error is thrown and later rescued, that's not really what it's intended for. In fact if you try that out, take a look at the error message.

def throw_rescue
  throw StandardError, "There was an error."
rescue => e
  puts e
end
Enter fullscreen mode Exit fullscreen mode
throw_rescue
$> uncaught throw StandardError
Enter fullscreen mode Exit fullscreen mode

Theuncaught throw tells us explicitly that we should be using catch somewhere. Additionally, look what we're missing: a stacktrace. Because throwing and catching are meant to be part of an expected flow, it makes sense that they wouldn't have a a stacktrace. So in this case we get a misleading exception (uncaught throw vs. StandardError) and no additional information about the source of the error.

What makes this even more confusing is that we can throw more than just symbols. As we see above, we can throw class names, like Exceptions. But we really shouldn't, as the example above should demonstrate.

Looking back at our original question about our RSpec test, we can see now why this test won't ever pass:

expect { throw StandardError.new("error") }.to raise_error

We can't expect throwing an error to raise that error, because throwing and raising aren't the same thing in Ruby. If you want to handle errors, do so with raise..rescue, not try..catch.

Here are some additional resources on raise..rescue and throw..catch:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .