Pattern Matching and Modelling Data

Article · 13:00

case/in matches structure, not just value: it destructures and binds in the same expression, which is what makes handling nested data readable. And it is how you model a set of states so that none of them can be quietly missed.

This lesson has 5 runnable examples. Press Try yourself! under any of them to open it in the editor.

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.

RB Example 1 17 lines
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

RB Example 2 23 lines
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

RB Example 3 38 lines
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.

RB Example 4 24 lines
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.

RB Example 5 29 lines
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/in matches 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_keys to make your own types matchable.
  • A missing case raises NoMatchingPatternError, which beats an else.