Pattern Matching and Modelling Data
case/in matches structure, not just value. It destructures and binds in the
same expression, which is what makes handling nested data readable.
def describe(value)
case value
in Integer => n if n.negative? then "negative integer #{n}"
in Integer | Float => n then "number #{n}"
in String => s then "string of #{s.length}"
in [] then "empty array"
in [Integer => only] then "one integer: #{only}"
in [_, _, *rest] then "two or more, #{rest.length} after the first two"
in { name: String => name } then "a record for #{name}"
in nil then "nothing"
else "something else"
end
end
[-5, 3.5, "hello", [], [7], [1, 2, 3, 4], { name: "Aisha", age: 30 }, nil, :sym].each do |v|
puts format("%-28s %s", v.inspect, describe(v))
end
Open this example in the editor and change it. Nothing you type is saved.
Note in { name: String => name }: hash patterns match on the keys you name
and ignore the rest, so extra fields never break the match.
Where it pays off: parsing a response
responses = [
{ status: 200, body: { items: [{ id: 1, title: "Aria" }, { id: 2, title: "Bloom" }] } },
{ status: 200, body: { items: [] } },
{ status: 404, error: { message: "no such playlist" } },
{ status: 500, error: { message: "database down", retryable: true } },
]
responses.each do |response|
message =
case response
in { status: 200, body: { items: [] } }
"no results"
in { status: 200, body: { items: [{ title: first_title }, *] } }
"first result: #{first_title}"
in { status: 404, error: { message: } }
"not found: #{message}"
in { status: Integer => code, error: { message:, retryable: true } }
"retryable #{code}: #{message}"
in { status: Integer => code }
"unhandled status #{code}"
end
puts message
end
Open this example in the editor and change it. Nothing you type is saved.
The equivalent with dig and if would be four nested nil checks. Here the
shape is the condition.
Binding, pinning and finding
config = { host: "localhost", port: 8080, tls: false }
# => binds the whole match; the bare key form binds that key's value.
config => { host:, port: }
puts "#{host}:#{port}"
# ^ pins an existing variable, so it compares instead of rebinding.
expected_port = 8080
case config
in { port: ^expected_port } then puts "port is as expected"
else puts "unexpected port"
end
# The find pattern searches an array for a matching run.
log = ["boot", "connect", "ERROR disk full", "retry", "ok"]
case log
in [*, /\AERROR/ => failure, *]
puts "found: #{failure}"
end
# in as a one-line boolean, and => as a one-line assertion that raises. The
# extra brackets around the in are needed: without them the parser reads
# puts(config) and then trips over the pattern.
puts((config in { tls: false }))
# A key that is present but does not match, and a key that is absent, raise
# different errors - which is worth knowing when you are reading a backtrace.
begin
config => { tls: true }
rescue NoMatchingPatternError => e
puts "value did not match: #{e.class}"
end
begin
config => { region: }
rescue NoMatchingPatternKeyError => e
puts "key was absent: #{e.class}"
end
Open this example in the editor and change it. Nothing you type is saved.
Making your own types matchable
Any object can take part by defining deconstruct for array patterns and
deconstruct_keys for hash patterns. Data and Struct define both already.
Point = Data.define(:x, :y)
case Point.new(x: 0, y: 5)
in { x: 0, y: } then puts "on the y axis at #{y}"
in { x:, y: 0 } then puts "on the x axis at #{x}"
in { x:, y: } then puts "at #{x},#{y}"
end
class Duration
attr_reader :seconds
def initialize(seconds) = @seconds = seconds
def deconstruct = [seconds / 60, seconds % 60]
def deconstruct_keys(keys) = { minutes: seconds / 60, seconds: seconds % 60, total: @seconds }
end
case Duration.new(185)
in [minutes, secs] then puts "#{minutes}m #{secs}s"
end
case Duration.new(45)
in { minutes: 0, seconds: } then puts "under a minute: #{seconds}s"
in { minutes: } then puts "#{minutes} minutes or more"
end
Open this example in the editor and change it. Nothing you type is saved.
deconstruct_keys is given the keys the pattern asked for, so an expensive
field can be computed only when something matches on it.
Modelling a domain as a set of shapes
Ruby has no sum type, but Data plus pattern matching gets you the same
discipline: a fixed set of shapes, and a match that fails loudly when a new one
appears.
Pending = Data.define(:submitted_at)
Approved = Data.define(:approver, :approved_at)
Rejected = Data.define(:reason)
def next_step(application)
case application
in Pending(submitted_at:)
"waiting since #{submitted_at}"
in Approved(approver:)
"approved by #{approver} - send the welcome email"
in Rejected(reason:)
"rejected (#{reason}) - notify and archive"
end
end
[
Pending.new(submitted_at: "2024-05-01"),
Approved.new(approver: "Marta", approved_at: "2024-05-03"),
Rejected.new(reason: "incomplete"),
].each { |a| puts next_step(a) }
# Add a fourth state and forget to handle it, and this raises rather than
# quietly falling through an else branch.
Withdrawn = Data.define(:withdrawn_at)
begin
next_step(Withdrawn.new(withdrawn_at: "2024-05-04"))
rescue NoMatchingPatternError => e
puts "unhandled state caught at runtime: #{e.class}"
end
Open this example in the editor and change it. Nothing you type is saved.
That last exception is the feature. An else returning "unknown" would have
shipped a silent bug.
What to take away
case/inmatches shape and binds parts in one expression.- Hash patterns ignore unmentioned keys; array patterns must match in full.
^pins a variable to compare rather than bind;*matches a run.=>is a one-line destructure that raises;(expr in pattern)is a boolean.- Define
deconstruct/deconstruct_keysto make your own types matchable. - A missing case raises
NoMatchingPatternError, which beats anelse.