skip to content

In a Ruby DSL that accepts any keyword through method_missing, why does a typo like sevrity go unnoticed, and how would you make DSL errors readable?

level: seniorimportance: should knowfreq 35%

answer

  1. catch-all turns typos into settings
  2. real methods get did_you_mean free
  3. DidYouMean::SpellChecker for custom errors
  4. validate the rule when the block ends
  5. raise with caller_locations, 3.4+

basics

~20 s

A catch-all method_missing stores every unknown call, so sevrity :high becomes a setting nobody reads. Define keywords as real methods so typos raise NoMethodError with a did_you_mean hint, validate each rule after its block, and point errors at the user's line.

solid answer

~50 s

Ghost-method keywords — a `method_missing` that records any one-argument call as a setting — make a DSL open-ended, but they also accept `sevrity :high` without complaint; the rule simply has no severity. Readable DSLs do the opposite: they **define each keyword as a real method** (often generated from a list), so an unknown name raises `NoMethodError`, and Ruby's built-in did_you_mean appends a `Did you mean?` suggestion of `severity` for free. If a catch-all is truly needed, restrict it to known names and raise `ArgumentError` with a suggestion from `DidYouMean::SpellChecker.new(dictionary: KEYWORDS).correct(name)`. Then **validate the finished rule** (missing threshold, bad severity) right after the block runs, and raise a DSL-specific error whose backtrace starts in the user's file: since Ruby 3.4 `raise` accepts `caller_locations` directly. The user should see their file and line, not the library's internals.

code

ruby · 18 lines
ruby
class AlertRule
  KEYWORDS = %i[severity threshold notify].freeze

  def initialize
    @settings = {}
  end

  KEYWORDS.each do |key|
    define_method(key) { |value| @settings[key] = value }
  end
end

AlertRule.new.instance_eval { sevrity :high }
# NoMethodError: undefined method 'sevrity' for an instance of AlertRule
# Did you mean?  severity

DidYouMean::SpellChecker.new(dictionary: AlertRule::KEYWORDS).correct(:sevrity)
# => [:severity]

go deeper

for a junior

Recall that method_missing catches any unknown call, so a misspelled DSL word may be accepted silently.

for a middle

Explain how real keyword methods turn typos into NoMethodError with a did_you_mean hint, and what a catch-all loses.

for a senior

Design validation after the block, a DSL error class, and backtraces that start at the user's line using caller_locations.

for a principal

Treat error messages as part of the DSL's interface, and weigh an open vocabulary against the support cost of silent typos.

## Ghost-method keywords A **ghost method** is a call that no method definition matches, handled by `method_missing`. DSL authors like it because any word can become a keyword: ```ruby def method_missing(name, *args) return super unless args.size == 1 @settings[name] = args.first end ``` Now `severity :high`, `threshold 90` and `runbook "..."` all work without declaring anything. But so does `sevrity :high`: the typo is stored under `:sevrity`, the real `:severity` stays unset, and the alert silently fires at the default severity. Nothing fails until someone notices a missed page. ## Make unknown words fail The fix is to make the vocabulary **closed** and the failure **early**: 1. **Declare keywords as real methods.** Generate them from a list with `define_method`, or write them out. An unknown word then raises `NoMethodError` the moment it runs. 2. **Let Ruby suggest the fix.** Ruby loads did_you_mean by default, so a `NoMethodError` for `sevrity` on an object that has `severity` already ends with a `Did you mean?` line suggesting `severity`. 3. **If you keep a catch-all,** check the name against the list and raise yourself, using the same speller Ruby uses: `DidYouMean::SpellChecker.new(dictionary: KEYWORDS).correct(name)` returns the close matches. 4. **Also implement `respond_to_missing?`** for any names a catch-all does accept, so `respond_to?` tells the truth (the ghost-method mechanics are their own topic). ## Validate when the block ends Per-call checks cannot see what is **missing**. After the block has run, the library knows the whole rule and can check it: - a required setting such as `threshold` was never given; - a value is out of range (`severity :urgent` when only `:low`, `:high` exist); - two settings contradict each other. Raise one error listing everything wrong with that rule, named by the rule's name. ## Point the error at the user's line A DSL error raised deep inside the library shows library frames first, which is the least useful part for the user. Options, all real Ruby: | Technique | Effect | |---|---| | `raise AlertDSL::Error, msg, caller_locations(1)` | backtrace starts at the caller of the raising method (Ruby 3.4+ accepts `Thread::Backtrace::Location` arrays; before, pass strings from `caller`) | | Include `block.source_location` in the message | names the file and line where the rule's block starts | | A dedicated `AlertDSL::Error < StandardError` | lets callers rescue DSL mistakes separately from bugs | | Loading a rules file with its real path | keeps the rule file's name in every backtrace line | ## Put together - Closed vocabulary: real methods, unknown words raise `NoMethodError` with a suggestion. - Semantic validation at the end of each block. - Errors that name the rule, the bad setting and the user's file and line. This costs a few lines of library code and saves every user from reading the library's source to understand their mistake. ## Testing the errors A DSL's error messages deserve tests like any other feature: 1. Evaluate a rule with a misspelled keyword and assert the error class and that the message contains the suggestion. 2. Evaluate a rule missing a required setting and assert that the message names the rule and the setting. 3. Assert that the first backtrace line points at the rule file used in the test, not at the library. These tests stop a refactoring of the library from quietly turning clear errors back into silent defaults or deep stack traces. ## Why it is asked Senior interviews use this to test DSL judgment rather than syntax: a candidate should see that `method_missing` keywords trade safety for flexibility, and that a DSL's error messages are part of its interface.

  • Why can't per-call checks catch a missing threshold?
    Each keyword method sees only its own call. A missing setting is the absence of a call, visible only once the whole block has run, so the library validates the finished rule after `instance_eval` or `yield` returns and raises there.
  • How do you make the backtrace start at the user's rule file rather than inside the library?
    Pass the frames yourself: `raise AlertDSL::Error, message, caller_locations(1)` in Ruby 3.4 or later, choosing the offset that skips library frames, or strings from `caller` on older versions. Adding `block.source_location` to the message also names the rule's file and line.

saying these in an interview costs you the question

  • A catch-all method_missing makes a DSL safer because nothing ever raises.
  • Typos in a method_missing DSL always raise NoMethodError.
  • did_you_mean must be required explicitly before Ruby suggests names.
  • Kernel#raise cannot take a custom backtrace argument.
  • Library frames at the top of a DSL error help the user most.