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}`)
}
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
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
}
Ruby
begin
# do stuff
rescue => e
# catch the error
ensure
# do this no matter what
end
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
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
throw_rescue
$> uncaught throw StandardError
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
: