skip to content

How do you make your own Ruby objects usable as when values, and what correctness and performance traps come with a custom ===?

level: seniorimportance: nice to knowfreq 24%

answer

  1. define === on the pattern
  2. return false, never raise
  3. when values evaluated every run
  4. hoist matchers into constants
  5. literal whens get a jump table

basics

~20 s

Define === 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 lines
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
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

for a junior

Know that anything defining === can follow when, and that a lambda works because Proc#=== calls it.

for a middle

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.

for a senior

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.

for a principal

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.