skip to content

In a Ruby service where NoMethodError on nil keeps surfacing far from its cause, how do you stop nil spreading instead of silencing the errors?

level: seniorimportance: should knowfreq 35%

answer

  1. symptom far from cause
  2. decide per boundary: legal or not
  3. raise where it enters
  4. Data.define rejects omitted fields
  5. silencing hides unrelated bugs

basics

~20 s

Decide at each boundary whether a missing value is legal. If not, raise there with a clear error; if so, normalise it to "" or a null object. Blanket rescues and NilClass patches only move the failure.

solid answer

~50 s

A `NoMethodError` on `nil` is a symptom; the defect is wherever `nil` entered unchecked. At each boundary, such as request params, parsed JSON, environment variables or a database row, classify every value as required or optional. **Required** values are checked once, where they enter, and raise a domain error or `ArgumentError` naming the field; lookups that must not miss use the raising variant rather than `[]`. Required keyword arguments and `Data.define` value objects raise `ArgumentError` when a field is omitted, but an explicit `nil` still passes, so guard that too. **Optional** values, like a patient's middle name, are normalised on entry to `""` or to a null object that answers the same methods. Avoid `x = call rescue nil`, `rescue NoMethodError`, and reopening `NilClass` to swallow messages: each hides unrelated bugs and pushes the failure further from its cause.

code

ruby · 17 lines
ruby
Patient = Data.define(:first_name, :middle_name, :last_name) do
  def initialize(first_name:, last_name:, middle_name: "")
    raise ArgumentError, "first_name is required" if first_name.nil?
    raise ArgumentError, "last_name is required" if last_name.nil?

    super(first_name:, last_name:, middle_name: middle_name.to_s)
  end

  def full_name = [first_name, middle_name, last_name].reject(&:empty?).join(" ")
end

Patient.new(first_name: "Ada", last_name: "Lovelace").full_name
# => "Ada Lovelace"
Patient.new(first_name: "Ada", middle_name: nil, last_name: "Lovelace").full_name
# => "Ada Lovelace"
Patient.new(first_name: nil, last_name: "Lovelace")
# ArgumentError: first_name is required

go deeper

for a junior

Recall that the crash line is where nil was used, and that the fix belongs where the value first came from, not beside the crash.

for a middle

Explain raising lookups, required keywords and Data.define as ways to fail at entry, and normalising optional fields once.

for a senior

Demonstrate classifying boundary fields as required or optional, reject blanket rescues and NilClass patches in review, and insist on a test for each missing case.

for a principal

Decide where validation lives across services, at the edge or in domain objects, and how strictly to reject incomplete input versus accepting it with explicit absence.

## Symptom versus cause In Ruby, `nil` travels freely. A lookup returns it, an assignment stores it, a method returns it, and nothing complains until code sends it a message `NilClass` does not define. The resulting `NoMethodError` is reported at that **use site**, which may be several objects and files away from the **entry point** where `nil` first appeared. Fixing the use site, for example by adding a guard around one call, leaves every other use of the same value exposed. The durable fix moves the decision to where values enter the program. ## Classify each value at the boundary A **boundary** is any place data arrives from outside your code's control: HTTP parameters, a parsed JSON or CSV payload, `ENV`, a database row, a third-party API response. For each field, decide whether absence is legal: | Field on a patient-intake form | Absence legal? | Action at the boundary | |---|---|---| | first name | no | raise with a message naming the field | | last name | no | raise with a message naming the field | | middle name | yes | normalise `nil` to `""` | | allergies | yes, but "not asked" differs from "none" | keep `nil` and model it explicitly | The last row matters: sometimes `nil` carries meaning and should survive, but then the domain object must expose it deliberately, for example with an `allergies_recorded?` predicate, rather than letting callers stumble on it. ## Failing fast where nil enters For required values, fail as early and as loudly as possible: - **Raising lookups.** Where a missing key is a bug, use the lookup that raises on a miss (the `fetch` family on `Hash`, `Array` and `ENV`) instead of `[]`, which returns `nil`. - **Required keyword arguments.** `def initialize(first_name:, last_name:)` raises `ArgumentError` with `missing keyword` when a caller omits one. - **Value objects.** `Data.define` makes every member mandatory: omitting one raises `ArgumentError` in `initialize`. An explicitly passed `nil` is accepted, so add a guard in a custom `initialize` when `nil` is not allowed. - **Clear messages.** Raise with the field name, `ArgumentError, "first_name is required"`, so the report points at the entry point rather than a distant method call. ## Modelling legitimate absence For optional values, give absence a shape callers can use without branching: 1. **Normalise to an empty value** on entry when empty and missing mean the same thing, as for a middle name: `middle_name.to_s`. 2. **Use a null object** when callers need behaviour: a small class that answers the same methods with neutral results, such as a `NoInsurance` object whose `provider_name` returns `"self-pay"`. 3. **Expose a predicate** when the difference between "missing" and "empty" matters, and keep the raw `nil` private. ## Anti-patterns that silence instead of fix These make the error disappear from the log while the bad data keeps flowing: - **`value = call rescue nil`.** The rescue modifier catches every `StandardError`, including a genuine typo inside `call` or a failed network request, and turns them all into `nil`. - **`rescue NoMethodError`** around a block. A misspelled method call on a real object raises the same class, so genuine bugs are swallowed along with the nil ones. - **Reopening `NilClass`** to define `method_missing` returning `nil`. The change is global, affects every gem loaded in the process, and turns `nil` into a black hole that absorbs every message, so failures surface later with even less context. - **Guarding every call site.** Chains of nil checks or safe navigation at each use repeat the same decision many times and hide which absences were actually expected. ## Applying it in review When reviewing a fix for a nil error, ask where the value entered and whether the fix lives there. A change that adds a guard beside the crashing line is usually treating the symptom; a change that validates or normalises the field where it arrives, with a test for the missing case, removes the whole class of failure. A short checklist for such a review: 1. Which boundary did the `nil` come through, and is absence legal there? 2. If it is not legal, does the fix raise at that boundary with the field named in the message? 3. If it is legal, is the value normalised once, so no caller needs its own check? 4. Is there a test that feeds the missing value and asserts the chosen behaviour?

  • Why is x = compute rescue nil worse than it looks?
    The rescue modifier rescues every `StandardError`, not only a `NoMethodError` on nil. A typo inside `compute`, a failed parse or a timeout all become `nil`, the variable looks like a legitimate absence, and the real failure resurfaces later as another nil error with no trace of its cause.
  • Does Data.define protect you from nil fields on its own?
    Only partly. `Data.define` requires every member, so omitting one raises `ArgumentError` with `missing keyword`. An explicitly passed `nil` still satisfies that check, so a field that must not be nil needs a guard in a custom `initialize` before calling `super`.

saying these in an interview costs you the question

  • Adding rescue nil at the crashing line is an acceptable permanent fix
  • Defining NilClass#method_missing to return nil makes a codebase safer
  • Data.define rejects nil values for its members
  • Every call site should guard against nil independently
  • rescue NoMethodError only catches calls on nil