skip to content

A Ruby parking-permit predicate ends in an if with no else, and an API now returns null instead of false — why, and how would you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. implicit return of last expression
  2. no branch ran
  3. nil is falsy but not false
  4. be_falsey hides it
  5. explicit false or guard clause

basics

~20 s

A Ruby method returns its last expression, and an if without else evaluates to nil when its test fails. nil passes truthiness checks but serialises as null and fails == false, so return a real boolean via a guard clause or an explicit else.

solid answer

~50 s

A method like `def valid?(permit) = (permit.expires_at > Time.now if permit.zone == zone)` — or its multi-line equivalent — returns the value of its final expression, and an `if` with no `else` evaluates to `nil` whenever its test is falsy. Inside Ruby that often goes unnoticed, because `nil` behaves like `false` in `if valid?(p)`. It leaks as soon as the value is compared strictly (`valid? == false`), stored, or serialised — JSON turns it into `null`, and clients that distinguish `null` from `false` break. Tests written with `be_falsey` or Minitest's `refute` accept `nil` too, so they do not catch it. Fix the method so every path returns `true` or `false`: a guard clause (`return false unless permit.zone == zone`), an explicit `else false`, or a single boolean expression built from comparisons. Then pin it with `eq(false)` or `assert_equal false`.

code

ruby · 17 lines
ruby
require "json"

Permit = Struct.new(:zone, :expires_at)

def leaky_valid?(permit, zone)
  permit.expires_at > Time.now if permit.zone == zone
end

def valid?(permit, zone)
  return false unless permit.zone == zone

  permit.expires_at > Time.now
end

permit = Permit.new("B", Time.now + 3600)
puts JSON.generate(valid: leaky_valid?(permit, "A"))  # {"valid":null}
puts JSON.generate(valid: valid?(permit, "A"))        # {"valid":false}

go deeper

for a junior

Recognise that a method returns its last expression and that an if without else gives nil when its test fails.

for a middle

Trace which callers notice nil versus false: truthiness checks do not, strict comparisons and JSON do. Rewrite with a guard clause or an else false.

for a senior

Diagnose the null in the payload back to the missing else, explain why be_falsey or refute let it through, and fix both the method and the tests so the boolean contract is enforced.

for a principal

Decide team policy for predicate return types at API and persistence boundaries, including strict matchers for predicates and whether to enable the pending RuboCop cops that police them.

## The symptom A permit service exposes `valid?`, and the JSON API has been returning `"valid": false` for years. After a refactor, some responses show `"valid": null`. The Ruby-side callers still work, but a mobile client that treats `null` as "unknown" now shows a spinner instead of "permit invalid". Nothing raised; nothing logged. ## The mechanism The refactor produced something like this: ```ruby def valid?(permit) permit.expires_at > Time.now if permit.zone == zone end ``` Two Ruby rules combine: 1. **Implicit return.** A method returns the value of its last evaluated expression; here that is the whole modifier `if`. 2. **A conditional with no branch taken evaluates to `nil`.** When `permit.zone == zone` is false, the comparison `expires_at > Time.now` never runs, and the `if` yields `nil`. So the method has three possible results — `true`, `false` (zone matches, but expired) and `nil` (wrong zone) — while its name promises two. ## Why it stays hidden | Consumer | Sees `nil` as | Bug visible? | |---|---|---| | `if valid?(p)` / `unless valid?(p)` | falsy, same as `false` | no | | `valid?(p) == false` | not equal to `false` | yes | | `JSON.generate(valid: valid?(p))` | `null` | yes, to clients | | `expect(valid?(p)).to be_falsey` | passes | no | | `refute valid?(p)` (Minitest) | passes | no | | `expect(valid?(p)).to eq(false)` / `be(false)` | fails | yes | | `assert_equal false, valid?(p)` | fails | yes | Most of the codebase only asks "truthy or not", which is exactly why the missing `else` survived review. RSpec's `be_falsey` and Minitest's `refute` both accept any falsy value by design — they are the wrong matchers for a method whose contract is a strict boolean. ## Fixing the method Pick one of three shapes; all return exactly `true` or `false`: ```ruby # 1. Guard clause def valid?(permit) return false unless permit.zone == zone permit.expires_at > Time.now end # 2. Boolean expression: == and > already return true or false def valid?(permit) permit.zone == zone && permit.expires_at > Time.now end # 3. Explicit else def valid?(permit) if permit.zone == zone permit.expires_at > Time.now else false end end ``` Notes on the choices: - The **guard clause** scales best when more preconditions arrive (revoked, unpaid), each on its own line. - The **boolean expression** is only strictly boolean because both operands are comparisons; if an operand could return `nil` (say `permit.revoked_at`), the expression would return that `nil`. Check the operands, not just the operator. - `!!expr` coerces any value to a boolean, but it hides *why* a value was not boolean; prefer fixing the branches. ## Pinning the contract - Change the tests to strict matchers: `eq(false)` or `be(false)` in RSpec, `assert_equal false, ...` in Minitest, for the "wrong zone" case specifically. - Serialise explicitly at the API boundary when the field is part of a contract, rather than passing through whatever a predicate returns. - RuboCop has help here, though not on by default: `Style/ReturnNilInPredicateMethodDefinition` (pending) flags `return`/`return nil` inside `?` methods, and `Naming/PredicateMethod` (pending) checks that predicate methods end with `?` and non-predicate methods do not. Neither catches every implicit-`nil` branch, so tests remain the real guard. ## A nuance worth stating Ruby's convention for `?` methods is **truthiness**, not strict booleans — core itself has predicates that return other values: `Numeric#nonzero?` returns `self` or `nil`, and `File.size?` returns the size or `nil`. So the implicit `nil` is not a language error. It becomes a bug when **your** method's contract (an API field, a persisted column, a comparison) needs `true`/`false`. The senior move is to decide that contract explicitly and make every branch honour it.

  • Is `permit.zone == zone && permit.expires_at > Time.now` guaranteed to return a boolean?
    Here yes, because `&&` returns one of its operands and both operands are comparisons that return `true` or `false`. It is not a general guarantee: if either operand could be `nil` or another object, the expression returns that value. The guard-clause version makes the `false` explicit regardless of what the operands return.
  • Why did the existing tests not catch the nil?
    They asserted with `be_falsey` in RSpec or `refute` in Minitest, which pass for any falsy value, `nil` included. Those matchers check truthiness, which is the right check for a truthiness contract but not for a strict boolean. Switching the wrong-zone case to `eq(false)` or `assert_equal false, ...` makes the missing `else` fail immediately.
  • Should every Ruby method ending in ? return true or false?
    Ruby's own convention is only truthiness: core predicates such as `Numeric#nonzero?` return `self` or `nil`, and `File.size?` returns the size or `nil`. Many teams still require strict booleans from their own `?` methods, especially when results cross an API or persistence boundary. Decide the contract, then enforce it with strict tests.

saying these in an interview costs you the question

  • A method ending in an if with no else returns false when the test fails.
  • nil and false are interchangeable everywhere, so a predicate returning nil is harmless.
  • be_falsey in the spec proves the method returns false.
  • Ruby core guarantees that every method ending in ? returns true or false.
  • The nil only matters if some caller checks the result with an if.