Solution:
class FooError < StandardError
attr_reader :foo
def initialize(foo)
super
@foo = foo
end
end
This is the best way if you follow the Rubocop Style Guide and always pass your message as the second argument to raise
:
raise FooError.new(foo), 'argh'
You can get foo
like this:
rescue FooError => error
error.foo # => 1234
error.message # => 'argh'
If you want to customize the error message then write:
class FooError < StandardError
attr_reader :foo
def initialize(foo)
super
@foo = foo
end
def message
"The foo is: #{foo}"
end
end
This works great if foo
is required. If you want foo
to be an optional argument, then keep reading.
If you don't follow the Rubocop Style Guide
And want this to work:
raise FooError.new('argh', foo)
You need to pass the message to super
as the only argument:
class FooError < StandardError
attr_reader :foo
def initialize(message, foo)
super(message)
@foo = foo
end
end
Explanation:
Pass your message as the second argument to raise
As the Rubocop Style Guide says, the message and the exception should be passed separately. If you write:
raise FooError.new('argh')
And want to pass a backtrace, there is no way to do it without passing the message twice:
raise FooError.new('argh'), 'argh', other_error.backtrace
You need to pass a backtrace if you want to re-raise an exception as a new instance with the same backtrace and a different message or data. Sometimes this is very useful.
Why is this so complicated?
The crux of the problem is a design flaw in Ruby: exception messages get set in two different ways.
raise StandardError, 'argh' # case 1
raise StandardError.new('argh') # case 2
In case 1, raise
just calls StandardError.new('argh')
, so these are the same. But what if you pass an exception instance and a message to raise
?
raise FooError.new(foo), 'argh', backtrace
raise
will set 'argh'
as the message on the FooError
instance, so it behaves as if you called super('argh')
in FooError#initialize
.
We want to be able to use this syntax, because otherwise, we'll have to pass the message twice anytime we want to pass a backtrace:
raise FooError.new(foo, 'argh'), 'argh', backtrace
raise FooError.new('argh', foo), 'argh', backtrace
But what if foo
is optional? Then FooError#initialize
is overloaded.
raise FooError, 'argh' # case A
raise FooError.new(foo), 'argh' # case B
In case A, raise
will call FooError.new('argh')
, but your code expects an optional foo
, not a message. This is bad. What are your options?
accept that the value passed to FooError#initialize
may be either foo
or a message.
Don't use case A style. If you're not passing foo
, write raise FooError.new(), 'argh'
Make foo a keyword argument
IMO, don't do 2. The code's not self-documenting, so you have to remember all of this. Too complicated.
If you don't want to use a keyword argument, my implementation of FooError
way at the top of this answer actually works great with 1. This is why FooError#initialize
has to call super
and not super()
. Because when you write raise FooError, 'argh'
, foo
will be 'argh'
, and you have to pass it to the parent class to set the message. The code doesn't break if you call super
with something that isn't a string; nothing happens.
3 is the simplest option, if you're ok with a keyword argument - h/t Lemon Cat. Here's the code for that:
class FooError < StandardError
attr_reader :foo
def initialize(message, foo: nil)
super(message)
@foo = foo
end
end
raise FooError, 'message', backtrace
raise FooError(foo: foo), 'message', backtrace