Skip to content

case/in matches shape, not just value

Level: 201 · anyone who has written if h[:type] == "click" && h.key?(:x) three times in a row

One line: case … in tests a value against a pattern — an array shape, a hash shape with the keys you name, a class, a nested mix of them — and binds variables as it goes; Python's match/case (3.10) does the same with sequence, mapping, class and capture patterns, minus Ruby's find pattern, its one-line value => pattern and its boolean value in pattern.

Where case/when asks each candidate candidate === subject, case/in asks whether the subject has the shape of a pattern. [Integer => a, *rest] matches an array whose first element is an Integer, binds it to a and the remainder to rest. {name: String => n, age:} matches a hash that has a :name holding a String and an :age, binds both, and ignores any other keys unless **nil says there must be none. Patterns nest to any depth, a find pattern [*, "admin", *post] looks for an element anywhere in an array, | joins alternatives, and the same patterns work outside case: config => {host:, port:} matches or raises, and value in pattern is a boolean. Any object can take part — a Struct or Data answers both array and hash patterns, and the next lessons show how a class of your own joins in. The program measures each form.

Verified output of case_in_pattern_matching_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. [1, 2, 3] in [Integer => a, *rest]           -> a = 1, rest = [2, 3]
 2. person in {name: String => n, age:}          -> n = "Ada", age = 36  (city: ignored)
 3. {name:, **nil} vs {name:, **rest}            -> extra: [:age, :city] / exact
 4. nested {user: {roles: [String => first, *]}} -> n = "Ada", first = "dev"
 5. find pattern [*, "admin", *post]             -> post = ["ops"]
 6. alternatives [_, _] | [_, _, _]              -> "pair or triple"
 7. rightward config => {host:, port:}           -> host = "localhost", port = 80
 8. boolean: value in pattern                    -> {a: 1} in {a: Integer} -> true; 5 in String -> false; 5 in Integer => five binds five = 5
 9. nil pattern                                  -> "matched nil"
10. Struct: in [x, y] / in {x:, y:}              -> [1, 2] / [1, 2]
11. Data: {lat:, lng:} / Coord[a, b] / Coord(k:) -> [1.5, 2.5] / [1.5, 2.5] / [1.5, 2.5]
12. one line: case 5 in X / case 5; in X         -> SyntaxError / compiles
13. hash pattern keys are symbols                -> in {"name" => n} -> SyntaxError; {"name" => "Ada"} in {name:} -> false

Reading the output

Row 1 is the array pattern: Integer => a is a class check and a binding, and *rest takes the remaining elements. Row 2 is the hash pattern: person has three keys, the pattern names two, and the match succeeds with city: ignored — a hash pattern is a subset test. Row 3 shows the two ways to say something about the keys you did not name: **nil demands that there are none, so the three-key hash fell through to the second branch, whose **rest collected [:age, :city], while the one-key hash was "exact". Row 4 nests a hash pattern inside a hash pattern inside an array pattern, and row 5 is the find pattern: [*, "admin", *post] scanned for "admin" and bound what followed it.

Row 6 joins two patterns with | — with _ placeholders, because a named variable inside an alternative is a syntax error (the next lesson measures it). Row 7 is the rightward form: config => {host:, port:} is a whole pattern match on one line, binding two locals; it raises when the shape is wrong (two lessons on). Row 8 is the boolean form, value in pattern, which returns true or false and, as 5 in Integer => five shows, still binds. Row 9: nil is a pattern like any literal.

Rows 10 and 11: a Struct answers an array pattern and a hash pattern, and a Data object answers a hash pattern as well as the two forms that also check the class — Coord[lat, lng] and Coord(lat:, lng:). Row 12 is a parsing trap: on one line, case 5 in Integer reads 5 in Integer as row 8's boolean test and is a SyntaxError; the subject needs a ; or a newline before in. Row 13: the keys in a hash pattern are symbols, {"name" => n} does not even compile, and a string-keyed hash never matches {name:}.

Compared with Python

Python's match (PEP 634) has the same four families: rows 1 and 4 are sequence patterns with int() as a for check-and-bind and *rest for the remainder, row 2 a mapping pattern that likewise ignores extra keys, rows 10 and 11 class patterns — Point(x, y) positional through __match_args__, Coord(lat=lat, lng=lng) by keyword — and row 6 alternatives with |. The differences are in what is missing. Row 3: **rest collects extra keys but there is no **nil; forbidding extras takes a guard. Row 5: a sequence pattern allows one *, so the find pattern is a SyntaxError and a guard with in does the search. Row 7: there is no one-line form — match is a block statement, as row 12 also shows — so a single-shape destructuring is a match with one case. Row 8: in is the membership test, so 5 in int is a TypeError; the boolean question is isinstance. Row 11 adds a difference in the other direction: a dataclass is not a sequence, so [lat, lng] does not match it, where Ruby's Data answers [a, b] through Coord[lat, lng]. Row 13 is the one freedom Python has and Ruby lacks: mapping-pattern keys are any literal, so {"name": n} matches a string-keyed dict.

Verified output of case_in_pattern_matching_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. [1, 2, 3] in [Integer => a, *rest]           -> a = 1, rest = [2, 3]
 2. person in {name: String => n, age:}          -> n = 'Ada', age = 36  (city ignored)
 3. {name:, **nil} vs {name:, **rest}            -> extra: ['age', 'city'] / exact  (guard `if not rest`; {**None} is a SyntaxError)
 4. nested {user: {roles: [String => first, *]}} -> n = 'Ada', first = 'dev'
 5. find pattern [*, "admin", *post]             -> SyntaxError for two stars; guard `if "admin" in roles` -> post = ['ops']
 6. alternatives [_, _] | [_, _, _]              -> 'pair or triple'
 7. rightward config => {host:, port:}           -> SyntaxError; match with one case -> host = 'localhost', port = 80
 8. boolean: value in pattern                    -> 5 in int -> TypeError (membership test); isinstance(5, str) -> False
 9. nil pattern                                  -> 'matched None'
10. Struct: in [x, y] / in {x:, y:}              -> [1, 2] via [x, y] / [1, 2] via Point(x, y)
11. Data: {lat:, lng:} / Coord[a, b] / Coord(k:) -> [1.5, 2.5] via Coord(lat=, lng=) / [1.5, 2.5] via Coord(lat, lng) / [lat, lng] -> 'no match'
12. one line: case 5 in X / case 5; in X         -> SyntaxError / compiles
13. hash pattern keys are symbols                -> case {"name": n}: matches a str-keyed dict -> n = 'Ada'

The Python library has no page on match; PEP 636 ↗ is the tutorial and PEP 634 ↗ the specification.

Try it

  1. Dispatch a list of event hashes — {type: "click", x:, y:}, {type: "key", key:} with an optional modifiers: array, {type: "resize", size: [w, h]}, and one unknown — with one case/in per shape, binding the fields each shape carries; use a find pattern to spot "ctrl" among the modifiers, and an else for the rest.
  2. Pull host and port out of {server: {host: "h", port: 1}, tls: false} with one rightward =>, then guard an if with config in {tls: false => tls} and print what tls was bound to.
Solution to 1

case_in_pattern_matching_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: dispatch a list of event hashes with case/in, one pattern per
# shape, binding the fields each shape carries, with an else for the rest.

events = [
  {type: "click", x: 10, y: 20},
  {type: "key", key: "q", modifiers: ["ctrl"]},
  {type: "resize", size: [80, 24]},
  {type: "key", key: "x"},
  {type: "boom"}
]

events.each do |event|
  line = case event
         in {type: "click", x:, y:} then "click at #{x},#{y}"
         in {type: "key", key:, modifiers: [*, "ctrl", *]} then "ctrl-#{key}"
         in {type: "key", key:} then "key #{key}"
         in {type: "resize", size: [w, h]} then "resize to #{w}x#{h}"
         else "unknown event #{event[:type].inspect}"
         end
  puts line
end

Verified output of case_in_pattern_matching_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

click at 10,20
ctrl-q
resize to 80x24
key x
unknown event "boom"

See also