Polyglot
Languages Ruby pattern matching
Ruby § pattern-matching

Pattern matching

Ruby 3.0 (December 2020) introduced pattern matchingcase/in expressions that admit destructuring arrays, hashes, and objects in addition to value-based matching. The principal forms: array patterns ([a, b, c]), hash patterns ({ name:, age: }), find patterns ([*, x, *]), value patterns (1, :active, nil), type patterns (Integer), array-rest patterns ([head, *rest]), the pin operator (^var), and guards (if condition). The form admits substantial conciseness for substantial discrimination on data shape — particularly useful for parsing JSON, decomposing API responses, and dispatching on tagged unions. The classical case/when (treated in Conditionals) remains for value-based dispatch; the new case/in adds structural matching. Ruby 3.1+ admits the one-line form (expr => pattern) for inline assertions.

case/in

The principal form (note the in keyword instead of when):

case point
in [0, 0]
  "origin"
in [x, 0]
  "on x-axis at #{x}"
in [0, y]
  "on y-axis at #{y}"
in [x, y]
  "at (#{x}, #{y})"
end

Each in clause specifies a pattern; the first matching pattern’s body runs. Variables in the pattern are bound to the matched components.

The patterns are exhaustive by convention — case/in raises NoMatchingPatternError if no pattern matches:

case "hello"
in Integer
  puts "an integer"
in Float
  puts "a float"
end
# raises NoMatchingPatternError

For non-exhaustive matching, add an else:

case input
in Integer then "int"
in String then "str"
else "other"
end

Array patterns

Match arrays by structure:

case arr
in []
  "empty"
in [single]
  "one: #{single}"
in [a, b]
  "pair: #{a}, #{b}"
in [first, *rest]
  "first: #{first}; rest: #{rest.inspect}"
in [*init, last]
  "init: #{init.inspect}; last: #{last}"
end

The *rest admits collecting remaining elements; conventional for variable-length matching.

The patterns admit literal values and type checks:

case command
in [:add, x, y]
  x + y
in [:sub, x, y]
  x - y
in [:mul, x, y]
  x * y
end

case data
in [Integer => first, *_]
  "first int: #{first}"
end

The Integer => first admits “match an Integer, bind to first” — type-check plus binding.

Hash patterns

Match hashes by key:

case user
in { name: }
  "name: #{name}"
in { name:, age: }
  "name: #{name}, age: #{age}"
in { type: :admin, name: }
  "admin: #{name}"
end

The shorthand { name: } is equivalent to { name: name } — binds the value to a local of the same name.

For matching specific values:

case response
in { status: 200, body: }
  process_body(body)
in { status: 404 }
  not_found
in { status: 500..599, error: }
  server_error(error)
end

A subtle point: hash patterns do not require all keys — extra keys are admitted:

case { name: "Alice", age: 30, email: "a@b.c" }
in { name: }                                      # matches; ignores extra keys
  puts name                                       # "Alice"
end

For matching only specific keys (no extras), use the special { ** } pattern:

case { name: "Alice", age: 30 }
in { name:, **nil }                               # match only if no other keys
  puts name
end
# raises NoMatchingPatternError because age is also present

Type patterns

Class names admit type-check matching:

case value
in Integer
  "integer"
in Float
  "float"
in String
  "string"
in Array
  "array"
in Hash
  "hash"
in nil
  "nil"
in true
  "true"
in false
  "false"
end

Combined with destructuring:

case data
in [Integer, *]                                   # array starting with an integer
  "int-led"
in [String, String]                               # exactly two strings
  "two strings"
in { name: String, age: Integer }
  "valid user"
end

Find patterns

The [*, target, *] admits “find target anywhere in the array”:

case [1, 2, 3, 4, 5]
in [*, 3, *]
  "contains 3"
end

case ["log", "error", "Failed", "to connect"]
in [*, /^Fail/, *]
  "contains a 'Fail' line"
end

Find patterns admit binding components:

case [1, 2, 3, 4, 5]
in [*pre, 3, *post]
  "pre: #{pre.inspect}; post: #{post.inspect}"
end
# pre: [1, 2]; post: [4, 5]

The pin operator ^

Variables in patterns bind by default; to match against an existing variable’s value, the pin ^:

target = 42

case n
in 0 then "zero"
in ^target then "target match"                    # match if n == 42
in _ then "other"
end

Without ^, target would bind a new local variable (shadowing the outer); the ^ admits using the existing value.

The pin works with any expression (since Ruby 3.1):

case input
in ^(target.to_s) then "matched target as string"
end

Guards

A pattern may have an if (or unless) guard:

case n
in Integer => x if x.positive?
  "positive int"
in Integer => x if x.negative?
  "negative int"
in 0
  "zero"
end

The guard is evaluated after the pattern matches; if false, the case is rejected and the next pattern is tried.

Wildcards and binding

The _ matches anything without binding:

case data
in [_, _, third]                                  # ignore first two
  third
end

case msg
in { type: _, payload: }                          # ignore type
  process(payload)
end

For binding-with-pattern, =>:

case data
in [String => first, _, _]
  first.upcase
in Hash => h if h.size > 5
  "large hash"
end

The => name admits “match this pattern AND bind the matched value to name”.

Object patterns

For custom classes implementing deconstruct / deconstruct_keys:

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def deconstruct
    [x, y]                                        # for array patterns
  end

  def deconstruct_keys(keys)
    { x: x, y: y }                                # for hash patterns
  end
end

p = Point.new(1, 2)

case p
in [a, b]                                         # array pattern via deconstruct
  "[#{a}, #{b}]"
in { x:, y: }                                     # hash pattern via deconstruct_keys
  "{x: #{x}, y: #{y}}"
end

The deconstruct is called for array patterns; deconstruct_keys for hash patterns. The mechanism admits substantial integration with custom types.

For Data (Ruby 3.2+) and Struct, the methods are auto-generated:

Point = Data.define(:x, :y)

p = Point.new(x: 1, y: 2)

case p
in { x:, y: }
  puts "x: #{x}, y: #{y}"
end

Alternative patterns with |

The | admits “match any of these patterns”:

case input
in Integer | Float
  "numeric"
in String | Symbol
  "stringy"
in Array | Hash
  "collection"
end

The principal restriction: alternative patterns may not bind variables (since the bindings would differ across alternatives).

One-line pattern matching

Ruby 3.0+ admits the one-line form expr in pattern:

data = { name: "Alice", age: 30 }

if data in { name:, age: Integer => age }
  puts "Got #{name}, #{age}"
end

The form returns true if the pattern matches (and binds the variables); false otherwise.

For assertion-style matching (raises if no match), Ruby 3.0+ admits expr => pattern:

{ name: "Alice", age: 30 } => { name:, age: }
puts name                                         # "Alice"
puts age                                          # 30

[1, 2, 3] => [a, b, c]
puts a                                            # 1

The => form raises NoMatchingPatternError if the pattern doesn’t match — conventional for “I expect this shape”.

Common patterns

Tagged union dispatch

def evaluate(expr)
  case expr
  in [:lit, n]
    n
  in [:add, a, b]
    evaluate(a) + evaluate(b)
  in [:mul, a, b]
    evaluate(a) * evaluate(b)
  in [:neg, x]
    -evaluate(x)
  end
end

evaluate([:add, [:lit, 2], [:mul, [:lit, 3], [:lit, 4]]])
# 14

The pattern admits substantial expressiveness for AST-style data.

JSON dispatch

case json_response
in { status: "success", data: { items: Array => items } }
  process(items)
in { status: "error", code:, message: }
  handle_error(code, message)
in { status: "redirect", location: }
  follow(location)
end

The pattern admits substantial discrimination on API responses.

Decomposing structured data

case event
in { type: "click", x: Integer => x, y: Integer => y }
  click(x, y)
in { type: "key", key: String => key }
  press(key)
in { type: "resize", width:, height: }
  resize(width, height)
end

Extracting from arrays

case parsed_csv
in [headers, *rows] if headers.all? { |h| h.is_a?(String) }
  Table.new(headers, rows)
in [single_row]
  Row.new(single_row)
in []
  Empty.new
end

Validation with assertion-style matching

def parse_user(data)
  data => { name: String => name, age: Integer => age, email: }

  User.new(name: name, age: age, email: email)
rescue NoMatchingPatternError => e
  raise InvalidUserError, "missing or invalid fields: #{e.message}"
end

The => form admits substantial conciseness for validation-and-extraction.

Recursive list processing

def sum_list(arr)
  case arr
  in []
    0
  in [head, *tail]
    head + sum_list(tail)
  end
end

sum_list([1, 2, 3, 4, 5])                         # 15

The pattern admits Haskell-style list recursion.

State machine dispatch

case state
in { status: :idle }
  start
in { status: :running, started_at: }
  check_progress(started_at)
in { status: :done, result: }
  finalise(result)
in { status: :failed, error: }
  retry_or_abort(error)
end

Pinning for value-equality

expected_id = 42

case incoming
in { id: ^expected_id, payload: }
  process(payload)
in { id: }
  ignore(id)
end

Find pattern for substring detection

case file_contents
in [*, "ERROR", *]                                # contains ERROR line
  alert
in [*, /WARNING/, *]                              # contains WARNING-pattern line
  warn
end

Combined value and type matching

case input
in 0..10 => small
  "small: #{small}"
in 11..100 => medium
  "medium: #{medium}"
in Integer => large
  "large: #{large}"
in String => s if s.match?(/^\d+$/)
  "numeric string: #{s}"
end

Custom class with pattern matching

class HttpResponse
  attr_reader :status, :body

  def initialize(status, body)
    @status = status
    @body = body
  end

  def deconstruct
    [status, body]
  end

  def deconstruct_keys(keys)
    { status: status, body: body }
  end
end

response = HttpResponse.new(200, '{"ok": true}')

case response
in { status: 200..299, body: }
  parse_success(body)
in { status: 400..499 }
  client_error
in { status: 500..599 }
  server_error
end

A note on case/when vs case/in

The two forms differ:

FormUse
case/whenValue-based dispatch via ===
case/inStructural pattern matching
# case/when — uses ===
case n
when 1..10 then "small"
when Integer then "integer"
when /^\d+$/ then "digits"
end

# case/in — destructuring
case data
in [Integer => x, Integer => y]
  "point at (#{x}, #{y})"
in { type:, value: }
  "tagged: #{type}=#{value}"
end

The two coexist; when for the conventional value dispatch; in for structural matching with binding.

A note on the conventional discipline

The contemporary Ruby pattern-matching advice:

  • Use case/in for substantial structural dispatch.
  • Use case/when for value-based dispatch.
  • Use the one-line => for inline destructuring with assertion.
  • Use the one-line in for boolean shape testing.
  • Use array patterns ([a, *rest, last]) for sequence destructuring.
  • Use hash patterns ({ key: }) for keyword extraction.
  • Use the pin ^ for value matching against existing variables.
  • Use guards (if cond) for additional constraints.
  • Use deconstruct and deconstruct_keys for custom-class pattern matching.
  • Use Data.define (Ruby 3.2+) for value objects with auto pattern matching.
  • Trust exhaustivenessNoMatchingPatternError is conventional for unhandled cases.

The combination — case/in for structural dispatch, array/hash/value/type patterns, the pin operator for value matching, guards for additional constraints, the deconstruct protocol for custom classes, the one-line forms — is the substance of Ruby’s pattern matching. The mechanism admits substantial expressive power for parsing, dispatch, and validation; the conventional contemporary discipline reaches for it whenever the data is substantial enough that classical case/when would require elaborate if chains.