How do you make your own Ruby objects usable as when values, and what correctness and performance traps come with a custom ===?
answer
- define === on the pattern
- return false, never raise
- when values evaluated every run
- hoist matchers into constants
- literal whens get a jump table
basics
~20 sDefine === on the pattern object, or use a lambda, Method or Set, whose === already works. Keep it total so wrong input returns false, and build matchers once as constants, since when values are re-evaluated on every run.
solid answer
~40 s`case` calls `pattern === subject`, so any object defining `===` can follow `when`: a small matcher class, a lambda (`Proc#===` calls it), a `Method`, or a `Set` (`Set#===` is `include?`; `Set` is core in Ruby 4.0). Make `===` **total**: like `Range#===` and `Regexp#===`, return `false` for a subject of the wrong type — a matcher that calls `subject.status` on a String crashes the whole dispatch. `when` expressions are evaluated at runtime, in order, every time the case runs, so hoist inline lambdas and matchers into frozen constants and put cheap, common checks first. CRuby compiles a `case` whose `when` values are all literals into a hash-based jump; any non-literal value, or a redefined `===` on those core classes, falls back to sequential `===` calls. Outside `case`, RuboCop's `Style/CaseEquality` flags explicit `===`.
code
ruby · 24 linesclass StatusClass
def initialize(range)
@range = range
end
def ===(other)
code = other.respond_to?(:status) ? other.status : other
code.is_a?(Integer) && @range.cover?(code)
end
end
SERVER_ERROR = StatusClass.new(500..599).freeze
QUIET_HOURS = Set[0, 1, 2, 3, 4, 5].freeze # Set is core in Ruby 4.0
Response = Struct.new(:status)
[503, Response.new(502), "oops"].each do |event|
p(case event
when SERVER_ERROR then :page
else :ignore
end)
end # :page, :page, :ignore
p(QUIET_HOURS === Time.now.hour) # Set#=== is include?go deeper
Know that anything defining === can follow when, and that a lambda works because Proc#=== calls it.
Write a small matcher class with ===, use Set and Method objects as patterns, and explain why a matcher must return false instead of raising for the wrong kind of subject.
Keep matchers total and hoisted into frozen constants, order clauses by specificity and frequency, and explain how non-literal when values and redefined === lose CRuby's literal jump table.
Decide whether a family of custom matchers is a clean extension point or a hidden rule engine, and how to keep such dispatch discoverable, tested and cheap as rules grow.
## The extension point is === `case/when` has no special list of pattern types. It calls **`when_value === subject`** and uses the result's truthiness. Anything that responds to `===` can therefore sit after `when`: - a **custom matcher class** with its own `===`; - a **lambda or proc** — `Proc#===` invokes it "like Proc#call", and the Ruby docs say this is what allows a proc to be the target of a `when`; - a **`Method` object** — `Method#===` calls the method, so `when method(:retryable?)` works; - a **`Set`** — `Set#===` is an alias of `include?`; in Ruby 4.0 `Set` is a core class. ```ruby class StatusClass def initialize(range) @range = range end def ===(other) code = other.respond_to?(:status) ? other.status : other code.is_a?(Integer) && @range.cover?(code) end end SERVER_ERROR = StatusClass.new(500..599).freeze ``` Now `when SERVER_ERROR` matches both a bare Integer and a response object with a `status` method. ## Trap 1: a === that raises The core classes set the standard: `(1..4) === "a"` returns `false`, and `Regexp#===` returns `false` for `nil` or an Integer. A case dispatcher routinely sees unexpected input, so a custom `===` must be **total**: | Custom `===` body | Subject `"oops"` | Effect | |---|---|---| | `other.status >= 500` | String has no `status` | `NoMethodError` escapes the `case` | | `other >= 500` | String vs Integer | `ArgumentError` from comparison | | guarded with `respond_to?` / `is_a?` | falls through | returns `false`, next `when` is tried | The same applies to lambdas used as `when` values: `->(x) { x >= 500 }` raises for a String subject, and a lambda with the wrong arity raises `ArgumentError`. Guard inside the predicate, or place type-checking `when` clauses first. ## Trap 2: when values are evaluated on every run `when` expressions are ordinary expressions evaluated **each time the case executes**, in order, until one matches. Writing matchers inline therefore allocates on every call: ```ruby case status when ->(c) { c >= 500 } then :page # new lambda each call when 400..499 then :warn end ``` In a hot path — a monitoring bot classifying thousands of events per second — hoist matchers into **frozen constants**, and order `when` clauses so the most common and cheapest checks come first. Every miss before the match costs one `===` call. ## Trap 3: losing the literal jump table CRuby has a dedicated optimisation for the common case. When **every** `when` value is a special literal — an integer, float, symbol, string literal, `nil`, `true` or `false` — the compiler emits an `opt_case_dispatch` instruction backed by a hash from literal to branch, so matching is a hash lookup rather than a chain of `===` calls. Two things switch it off: 1. **One non-literal `when` value** (a constant, range, class, regexp or lambda) makes the compiler fall back to sequential `===` calls for that `case`. 2. **Redefining `===`** on Integer, Float, Symbol, String, nil, true or false makes the runtime skip the hash and fall back as well. This is rarely a bottleneck, but it is another reason **never to monkey-patch `===` on core classes**, and it explains why a large `case` over integer literals is fast. ## Trap 4: === outside case Because `===` means different things per class, calling it directly (`/re/ === str`, `Integer === x`) obscures intent. RuboCop's `Style/CaseEquality` (enabled) flags explicit `===`; the config offers `AllowOnConstant` and `AllowOnSelfClass` for teams that accept `Integer === x`. In ordinary code, prefer the intention-revealing method: `is_a?`, `cover?`, `match?`, `include?`. ## Checklist for a custom matcher 1. Define `===` (and only that) on a small, immutable object; freeze the instance. 2. Make it **total**: return `false` for inputs it does not understand. 3. Build it once as a constant; don't create lambdas or matchers inside the `when`. 4. Order clauses by specificity, then by frequency. 5. Test it directly with `matcher === value` in unit tests, including the "wrong type" inputs.
- Why should a custom === return false rather than raise for an unexpected subject?A `case` tries each `when` in turn, so one raising matcher aborts the whole dispatch even if a later branch would have handled the input. Core patterns set the precedent: `Range#===` returns `false` for an incomparable value and `Regexp#===` returns `false` for non-strings. Guard with `is_a?` or `respond_to?` inside `===`.
- Which when values keep CRuby's hash-based case dispatch, and what disables it?It applies when every `when` value is a special literal: integers, floats, symbols, string literals, `nil`, `true` or `false`. A single non-literal value such as a constant, range or class makes the compiler emit sequential `===` calls instead, and redefining `===` on those core classes makes the runtime bypass the hash.
saying these in an interview costs you the question
- when values are evaluated once when the method is defined, so inline lambdas are free.
- A custom === may raise for unexpected input because case catches errors and moves on.
- You must subclass a core class such as Range to make an object usable in when.
- Redefining Integer#=== is a safe way to customise how case matches numbers.
- Set needs require "set" in Ruby 4.0 before Set#=== can be used in a case.