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?
answer
- implicit return of last expression
- no branch ran
- nil is falsy but not false
- be_falsey hides it
- explicit false or guard clause
basics
~20 sA 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 sA 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 linesrequire "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
Recognise that a method returns its last expression and that an if without else gives nil when its test fails.
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.
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.
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.