Skip to content

Custom exceptions subclass StandardError

Level: 201 · anyone about to write class MyError < Exception

One line: Inherit from StandardError (never Exception), give initialize a default message and pass it to super, add readers for anything else the handler needs, and catch the whole family by its base class — full_message, detailed_message and the exception hook are the extras.

A custom exception is an ordinary class, and the only decision that matters is where it hangs in the tree: under StandardError, so that a bare rescue catches it, not under Exception, where it would be invisible to every rescue => e in every library that calls your code. initialize normally takes a message, with a default, and passes it to super, because Exception#message returns whatever super stored; anything else the handler will need — a status code, the record that failed — becomes an instance variable with a reader. A hierarchy of such classes lets a caller choose how broad to be: rescue NotFound for one case, rescue AppError for the family. Beyond that the protocol has a few hooks: message and to_s can be overridden, detailed_message (Ruby 3.2) is what an uncaught exception's report prints and can add a hint that message does not carry, full_message renders the report as a string, and raise accepts any object with an exception method, so a domain object can turn itself into an error on demand.

Verified output of custom_exception_classes_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. a default message via super
   raise AppError                  -> "the application failed"
   raise AppError, "given instead" -> "given instead"
2. extra attributes
   NotFound: "not found", code 404
   NotFound: "no such user", code 410
3. overriding message
   e.message "LOUD: x"   e.to_s "x"
4. a family is caught by its base class
   rescue AppError caught AppError
   rescue AppError caught NotFound
   rescue AppError caught Loud
5. full_message, first line (line number masked)
   custom_exception_classes_rb.rb:LINE:in 'Object#boom': gone (NotFound)
6. raise obj calls obj.exception: any object can be raised that way
   raise Ticket.new(7)                -> NotFound: "ticket 7 is missing"
   raise Ticket.new(7), "custom text" -> NotFound: "custom text"
7. detailed_message (3.2) is what full_message prints; message is untouched
   e.message           "bad setting"
   e.detailed_message  "bad setting (Detailed) [hint: check the config]"
   full_message line 1 custom_exception_classes_rb.rb:LINE:in '<main>': bad setting (Detailed) [hint: check the config]
8. initialize without super: the message falls back to the class name
   NoSuper: "NoSuper"
9. == compares class, message and backtrace
   AppError.new("a") == AppError.new("a")  true
   AppError.new("a") == AppError.new("b")  false
10. no exception groups: collect the errors and raise one wrapper
   3 errors: [ArgumentError, TypeError, ArgumentError]
   ArgumentErrors inside: 2, TypeErrors: 1

Reading the output

Row 1 is the default message: raise AppError says "the application failed" because initialize's default filled in and super stored it, while raise AppError, "given instead" replaces it. Row 2 adds a code: keyword — NotFound passes only the message up to super and keeps the code in @code, so the handler reads e.code beside e.message. Row 3 overrides message and shows the split it creates: e.message is "LOUD: x" but e.to_s is still "x", because the default message calls to_s and this override sits above it — override to_s instead if you want both to agree. Row 4 raises all three classes at one rescue AppError and it catches each, which is the reason to build a family.

Row 5 is full_message(highlight: false, order: :top), the string an uncaught exception prints, reduced to its first line: file, line (masked here), the method, the message and the class in parentheses. Row 6 is the exception protocol from the instance side: raise Ticket.new(7) works although Ticket is not an exception at all, because raise calls obj.exception, and with a second argument it calls obj.exception("custom text"). Row 7 shows detailed_message: the override appends a hint, full_message's first line carries it, and e.message does not — it is the right place for context meant for a person reading a crash report. Row 8 is the mistake of not calling super: the message silently becomes the class name. Row 9: Exception#== compares class, message and backtrace, so two unraised AppError.new("a") are equal. Row 10 is what Ruby does not have — exception groups — and the idiom in their place: collect the errors and raise one wrapper that carries them.

Compared with Python

The shape is the same — subclass Exception (Python's StandardError), call super().__init__(msg), add attributes — and rows 1, 2 and 4 print the same values. Row 3 differs in the hook: Python overrides __str__, and e.args still holds the original ('x',), the tuple every exception keeps. Row 5's counterpart is traceback.format_exception_only, whose last line is NotFound: gone — the class first, then the message, the reverse of Ruby's order — and, since 3.11, any notes. Row 6 has no equivalent of the exception hook: raise needs a BaseException class or instance, and raise Ticket(7) is a TypeError. Row 7's add_note (3.11) is the closest thing to detailed_message: the note appears in the traceback and not in str(e). Row 8 is a small surprise in Python's favour — BaseException.__new__ stores the constructor arguments in args even when __init__ never calls super(), so str(e) is '5' rather than a bare class name. Row 9 is the opposite of Ruby: == on exceptions is identity, so two equal-looking AppError("a") are not equal. Row 10 is what Python has and Ruby lacks: ExceptionGroup and except* (3.11), which split one group of three errors across two handlers by class.

Verified output of custom_exception_classes_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. a default message via super().__init__
   raise AppError                  -> 'the application failed'
   raise AppError("given instead") -> 'given instead'
2. extra attributes
   NotFound: 'not found', code 404
   NotFound: 'no such user', code 410
3. overriding __str__
   str(e) 'LOUD: x'   e.args ('x',)
4. a family is caught by its base class
   except AppError caught AppError
   except AppError caught NotFound
   except AppError caught Loud
5. format_exception_only: the last line of a traceback
   NotFound: gone
6. no exception protocol: raise needs a BaseException class or instance
   raise Ticket(7)  -> TypeError
7. add_note (3.11) is shown by the traceback; str(e) is untouched
   str(e)                 'bad setting'
   e.__notes__            ['hint: check the config']
   format_exception_only  ['AppError: bad setting', 'hint: check the config']
8. __init__ without super().__init__: args are kept anyway
   NoSuper: str '5', args (5,)
9. == is identity
   AppError("a") == AppError("a")  False
   a == a                            True
10. ExceptionGroup and except* (3.11): several errors at once
   except* ValueError got 2 of them
   except* TypeError  got 1 of them

The Python tutorial covers subclassing under User-defined Exceptions ↗ and groups under Raising and Handling Multiple Unrelated Exceptions ↗.

Try it

  1. Define ValidationError < StandardError that takes a field name, keeps it in a field reader and builds its default message from it ("age is invalid"), plus MissingField < ValidationError whose message is "name is required". Write validate(record) that raises MissingField when :name is absent and ValidationError when :age is not an Integer, and run three records through a rescue ValidationError => e that prints e.class, e.message and e.field.
  2. Override detailed_message in ValidationError to append [field: name], and show that the first line of full_message(highlight: false) carries it while message does not.
Solution to 1

custom_exception_classes_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: a small error family that carries the field it is about, builds its
# default message from that field, and is caught as a family by its base class.

class ValidationError < StandardError
  attr_reader :field

  def initialize(field, msg = "#{field} is invalid")
    @field = field
    super(msg)
  end
end

class MissingField < ValidationError
  def initialize(field)
    super(field, "#{field} is required")
  end
end

def validate(record)
  raise MissingField, :name unless record.key?(:name)
  raise ValidationError, :age unless record[:age].is_a?(Integer)
  "ok"
end

[{ age: 3 }, { name: "Ann", age: "three" }, { name: "Ann", age: 30 }].each do |record|
  begin
    puts "   #{record.inspect} -> #{validate(record)}"
  rescue ValidationError => e
    puts "   #{record.inspect} -> #{e.class}: #{e.message} (field #{e.field.inspect})"
  end
end

Verified output of custom_exception_classes_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

   {age: 3} -> MissingField: name is required (field :name)
   {name: "Ann", age: "three"} -> ValidationError: age is invalid (field :age)
   {name: "Ann", age: 30} -> ok

See also