Skip to content

rescue works as a modifier, for StandardError only

Level: 201 · anyone tempted to write value = risky rescue default

One line: expr rescue fallback evaluates expr and, if it raises a StandardError, yields fallback instead; it binds looser than assignment, cannot stand bare in an argument list, and hides every StandardError alike — a typo's NoMethodError as readily as the ArgumentError you meant.

rescue has a one-line form, in the family of if and unless modifiers: expr rescue fallback. It catches StandardError — the same default as a bare rescue clause — and nothing else, so a NotImplementedError or a SystemExit passes through it. It binds after assignment: x = expr rescue 0 is x = (expr rescue 0), which is the reading you want, and in an argument list it needs its own parentheses because a bare p(expr rescue 0) does not parse. Two related rules concern blocks: since Ruby 2.5 a do … end block may carry rescue, else and ensure clauses like a method body, but a { } block may not — the modifier is the only rescue a brace block can hold. The form is popular for parsing — Integer(text) rescue 0 — and the same program shows the alternative that does not swallow unrelated errors: Integer(text, exception: false) returns nil for bad input and still raises for everything else.

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

1. expr rescue fallback
   Integer("abc") rescue 0  -> 0
   Integer("42") rescue 0   -> 42
2. it catches StandardError only
   (raise NotImplementedError) rescue 0 -> propagated NotImplementedError
3. how it binds
   value = raise("boom") rescue "fallback"  -> value is "fallback"
   as a bare argument, p(expr rescue 0):   SyntaxError
   with its own parentheses, p((expr rescue 0)): compiles
4. a rescue clause in a def needs no begin
   parse("q") -> "method-level rescue"
5. a rescue clause inside do...end works; inside { } it is a syntax error
   do |x| ... rescue => e ... end   compiles
   { |x| ... rescue => e ... }      SyntaxError
   rescued inside do...end: "inside a do block (1)"
6. the modifier form is fine inside { }
   modifier inside braces (1)
7. Integer() raises where to_i does not
   "abc".to_i                    -> 0
   "12abc".to_i                  -> 12
   Integer("abc")                -> ArgumentError: invalid value for Integer(): "abc"
   Integer("abc", exception: false) -> nil
8. the trap: the modifier hides every StandardError, including a typo
   nil.upcse rescue "default" -> "default" (the NoMethodError from the typo is gone)

Reading the output

Row 1 is the form in use: Integer("abc") raises ArgumentError, so the expression yields 0; Integer("42") does not raise, so the fallback is never evaluated and x is 42. Row 2 raises a NotImplementedError under the modifier and it comes straight through to the outer rescue NotImplementedError — the modifier is rescue StandardError, nothing wider. Row 3 is precedence: value = raise("boom") rescue "fallback" leaves value holding "fallback", because the modifier binds before the assignment does; and the compiles? helper (which asks RubyVM::InstructionSequence.compile and reports the class of the failure) shows that p(expr rescue 0) is a SyntaxError while p((expr rescue 0)) is fine.

Row 4 is the method-level clause from the first lesson, for contrast with the one-liner. Row 5 compiles two blocks that differ only in their delimiters: the do … end block with a rescue => e clause compiles and, when run, rescues; the { } block with the same clause is a SyntaxError. Row 6 shows the way round it — inside braces, the modifier form is legal. Row 7 is why the modifier is so often wrapped around Integer(): to_i never raises (it reads "12abc" as 12 and "abc" as 0), Integer() refuses anything that is not a whole number literal, and Integer(text, exception: false) gives nil without a rescue at all.

Row 8 is the argument against the modifier. nil.upcse is a typo for upcase; it raises NoMethodError, a StandardError, and the modifier turns it into "default". The bug is now invisible. The rule of thumb: use the modifier only when the expression can raise exactly one kind of error you have already thought about, and prefer exception: false, fetch with a default, or dig where they exist.

Compared with Python

Python has no expression form of try at all — row 1 compiles x = int('abc') except ValueError: 0 and gets a SyntaxError; that syntax was proposed in PEP 463 and rejected. (Not machine-checked here.) The idioms in its place are a small helper (or_default(f, default), which takes a lambda), and contextlib.suppress, which is a with block rather than an expression: row 2 shows it catching only the class it is given, and row 3 the consequence of it being a statement — when the suppressed line raises, the rest of the block is skipped, assignment included, so the default has to be set before the block. Row 4 matches Ruby's row 4 only in spirit: there is no function-level except, so the try is written out. Row 5 is Python's version of the brace-block rule: try cannot appear inside a lambda or any other expression, with or without parentheses. Row 6 lists what Python reaches for instead of a catch-all: dict.get, getattr with a default, next(it, default). Row 7 is the parsing difference: int() has no to_i beside it — "abc" and "12abc" both raise ValueError, and only surrounding whitespace is forgiven. Row 8 is the same trap in the same shape: a catch-all helper turns the typo's AttributeError into 'default'.

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

1. no expression form: try is a statement
   x = int('abc') except ValueError: 0  -> SyntaxError
   or_default(lambda: int('abc'), 0)    -> 0
   or_default(lambda: int('42'), 0)     -> 42
2. contextlib.suppress catches only what it is given
   suppress(ValueError) around raise NotImplementedError -> propagated NotImplementedError
3. how it binds: suppress skips the rest of its block, assignment included
   x after a suppressed int('abc') -> 'unchanged' (set the default before the block)
4. no def-level except: the try is written out
   parse('q') -> 'function-level except'
5. try cannot appear in a lambda or any expression
   f = lambda: try: 1 except: 2   SyntaxError
   f = lambda: (try: 1 except: 2) SyntaxError
6. the built-in defaults that replace most rescue modifiers
   d.get('b', 0)                 -> 0
   getattr(None, 'upper', 'dflt') -> 'dflt'
   next(iter([]), 'empty')        -> 'empty'
7. int() raises where Ruby's to_i does not
   int('abc'   ) -> ValueError
   int('12abc' ) -> ValueError
   int(' 42 '  ) -> 42
8. the same trap: a catch-all hides a typo
   or_default(lambda: None.upcse(), 'default') -> 'default' (the AttributeError is gone)

The Ruby text library's to_i never fails; Integer() does ↗ covers row 7 in depth — what each parser accepts, including 0x and 0b prefixes and non-ASCII digits. contextlib.suppress ↗ and PEP 463 ↗ are the Python references.

Try it

  1. Write to_number(text) that returns Integer(text), or Float(text) when that fails, or nil when both fail, using the modifier form for each step. Run it on "42", "4.2", "abc", "0x1f", "1e3" and "".
  2. Rewrite to_number with exception: false instead of the modifier, then introduce a typo (Integr(text)) into both versions and show which one reports the NameError and which one silently returns nil.
Solution to 1

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

# Exercise 1: to_number tries Integer(), then Float(), then gives up with nil, using
# the modifier form for each step.

def to_number(text)
  Integer(text) rescue (Float(text) rescue nil)
end

["42", "4.2", "abc", "0x1f", "1e3", ""].each do |text|
  puts "   #{text.inspect.ljust(7)} -> #{to_number(text).inspect}"
end

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

   "42"    -> 42
   "4.2"   -> 4.2
   "abc"   -> nil
   "0x1f"  -> 31
   "1e3"   -> 1000.0
   ""      -> nil

See also