In Ruby, how do you write a custom error class with extra fields and a default message that still works with raise Klass, "msg"?
answer
- inherit from StandardError
- optional positional message first
- call super(msg) to store it
- fields as keywords with attr_reader
- no super: message is the class name
basics
~10 sSubclass StandardError, define initialize(msg = "default text", field: nil), call super(msg) and store the field with attr_reader. raise Klass, "msg" then passes the message to new, while raise Klass.new(field: 30) sets the field.
solid answer
~40 sInherit from `StandardError`, not `Exception`, so ordinary rescue clauses catch it; RuboCop's `Lint/InheritException` enforces that. Give `initialize` an **optional positional message with a default**, then keyword fields: `def initialize(msg = "carrier rate limit hit", retry_after: nil)`, `super(msg)`, `@retry_after = retry_after`, plus `attr_reader :retry_after`. This shape matters because `raise RateLimited, "slow down"` calls `RateLimited.new("slow down")` with one positional argument: a required keyword would make that raise an `ArgumentError` instead of your error, and a required positional would break `raise RateLimited`. Calling `super(msg)` is what stores the message; forget it and `message` falls back to the class name. Fields are set with the instance form, `raise RateLimited.new(retry_after: 30)`.
code
ruby · 16 linesmodule ShippingRates
class Error < StandardError; end
class RateLimited < Error
attr_reader :retry_after
def initialize(msg = "carrier rate limit hit", retry_after: nil)
super(msg)
@retry_after = retry_after
end
end
end
raise ShippingRates::RateLimited # default message
raise ShippingRates::RateLimited, "UPS: slow down" # custom message
raise ShippingRates::RateLimited.new(retry_after: 30) # field set, default messagego deeper
Remember to inherit from StandardError, call super with the message, and expose fields with attr_reader.
Explain why initialize needs an optional positional message and optional keywords, tied to how raise calls new for each form.
Design error fields callers act on, such as retry_after, keep them small, and keep message plain while richer output goes elsewhere.
Standardise error class conventions across teams so every error is rescuable by class and carries machine-readable fields rather than prose.
## The shape that works with every raise form A custom error class is an ordinary Ruby class that inherits from an exception class. For errors meant to be rescued by application code, the parent is **`StandardError`** (or one of its subclasses), so that a bare `rescue` catches it. RuboCop's **`Lint/InheritException`**, enabled by default, reports classes that inherit from `Exception` directly and suggests `StandardError` (or `RuntimeError`, if configured) as the parent. The constructor has to satisfy three calling patterns: | Raise form | What Ruby calls | Requirement on `initialize` | |---|---|---| | `raise RateLimited` | `RateLimited.new` | every parameter optional | | `raise RateLimited, "slow down"` | `RateLimited.new("slow down")` | accepts one positional message | | `raise RateLimited.new(retry_after: 30)` | your call, as written | keywords for the fields | The signature that fits all three is an optional positional message followed by optional keywords: `def initialize(msg = "carrier rate limit hit", retry_after: nil)`. ## Storing the message `Exception#initialize` stores the message; `Exception#message` returns `to_s`, and `to_s` returns the stored message or, if none was stored, the **class name**. So: - **Call `super(msg)`.** Without it, `raise RateLimited, "slow down"` produces an error whose message is `"RateLimited"`, silently dropping the text. - **Pass the default through `super`.** A default in the parameter list gives every raise a useful message without overriding methods. - **Avoid overriding `message` to build text from fields** unless you also honour a passed-in message; otherwise `raise RateLimited, "custom"` ignores the custom text. If you want extra context in printed output, `detailed_message` is the hook designed for that. ## Adding fields Fields carry data the rescuer can act on: 1. Accept them as **keyword arguments** after the message. 2. Store them in instance variables. 3. Expose them with **`attr_reader`**. 4. Keep them simple values: numbers, strings, symbols. Large objects referenced from an error stay alive as long as the error does, for example in an error tracker's buffer. A rescuer then reads the field instead of parsing message text: `rescue ShippingRates::RateLimited => e` followed by `sleep(e.retry_after || 1)`. ## Pitfalls - **Required keywords.** `def initialize(sku:)` makes `raise InvalidSku, "bad"` fail with an `ArgumentError` about the missing keyword, so the caller sees the wrong error class. - **Required positionals.** `def initialize(msg, carrier)` breaks `raise CarrierError` and `raise CarrierError, "text"`. - **Mutable default messages.** A default that interpolates fields is fine; one that reads global state at raise time can surprise readers of logs. - **Inheriting from `Exception`.** The error escapes every bare `rescue` and `rescue => e`, which callers rarely expect. ## Minimal classes When a class needs no fields, the body can be empty: `class InvalidPostcode < Error; end`. Some codebases write the same thing as `InvalidPostcode = Class.new(Error)`. Both produce a named subclass; the explicit `class` form is easier to extend later with fields and a default message. ## Testing the class A custom error deserves a few quick tests, because a mistake in `initialize` surfaces only when the error is raised in production: 1. `raise Klass` produces the default message. 2. `raise Klass, "text"` keeps the custom text. 3. `Klass.new(field: value).field` returns the value. 4. `Klass.ancestors` includes the gem's base error and `StandardError`. In RSpec, `expect { ... }.to raise_error(ShippingRates::RateLimited) { |e| expect(e.retry_after).to eq(30) }` checks both the class and the field in one expectation; Minitest's `assert_raises` returns the exception so its fields can be asserted the same way.
- What happens if initialize requires a keyword argument and someone writes raise Klass, "msg"?`raise` calls `Klass.new("msg")` with only a positional argument, so Ruby raises `ArgumentError` for the missing keyword from inside `initialize`. The caller sees an `ArgumentError` rather than `Klass`, and a `rescue Klass` does not catch it. Make keywords optional, with defaults.
- Why not override message to build the text from the fields?Because `raise Klass, "custom"` would then ignore the custom text: the override wins over the stored message. Pass a default message through `super` instead, and if printed output needs extra context, override `detailed_message`, which the default error printer uses, leaving `message` as the plain text.
saying these in an interview costs you the question
- Custom errors should inherit from Exception so nothing swallows them
- Calling super in initialize is optional; the message is stored anyway
- A required keyword field is harmless for raise Klass, "msg"
- The rescuer should parse the message to get retry details
- raise Klass, "msg", field: 1 sets the field on the new error