skip to content

In Ruby pattern matching, why does a hash pattern match extra keys while an array pattern must match the whole array, and how do rest patterns change that?

level: middleimportance: should knowfreq 40%

answer

  1. arrays: whole, hashes: subset
  2. {} matches only empty
  3. **nil forbids extra keys
  4. *rest and **rest bind leftovers
  5. Symbol keys only: symbolize_names

basics

~20 s

An array pattern must account for every element, so [Integer, Integer] rejects a three-element array unless you add *rest. A hash pattern checks only the keys it names, so extras are ignored; **nil forbids them, **rest collects them, and {} matches only an empty hash.

solid answer

~40 s

Ruby's reference spells out the asymmetry: arrays match only as a *whole*, while a hash pattern "matches even if there are other keys besides the specified part". So `[Integer, Integer]` fails against `[1, 2, 3]`, and you add `*` or `*rest` to accept more elements. For hashes the default is lenient, which suits JSON payloads that grow new fields; `**nil` makes it strict (no other keys), `**rest` binds the leftovers as a Hash, and `{}` is the single exception that matches only an empty hash. Hash patterns match **Symbol keys only** — a key must be written as a label like `type:` — so a payload from `JSON.parse(body)` with String keys never matches; parse with `symbolize_names: true`. Key-only entries such as `text:` both require the key and bind a local of the same name.

code

ruby · 18 lines
ruby
require "json"

raw = '{"type":"message","text":"hi","ts":1700000000}'

p((JSON.parse(raw) in {type: "message"}))               # => false, String keys

event = JSON.parse(raw, symbolize_names: true)
p((event in {type: "message"}))                         # => true, extra keys ignored
p((event in {type: "message", text: String, **nil}))    # => false, ts is extra

case event
in {type: "message", text:, **meta}
  p text                                               # => "hi"
  p meta                                               # => {ts: 1700000000}
end

p(([1, 2, 3] in [Integer, Integer]))                   # => false, whole array
p(([1, 2, 3] in [Integer, *]))                         # => true

go deeper

for a junior

Know that array patterns must match every element and hash patterns ignore extra keys, and that {} matches only an empty hash.

for a middle

Use *rest, **rest and **nil deliberately, explain key-only binding, and remember that hash patterns match Symbol keys only, which matters for parsed JSON.

for a senior

Design payload patterns that tolerate producers adding fields, reserve **nil for strict internal formats, and normalise keys at the boundary so matches never fail silently.

for a principal

Decide how strictly external payloads are validated: lenient patterns for evolving third-party events versus strict schemas where unknown fields signal a contract breach.

## The asymmetry Ruby's pattern-matching reference highlights "an important difference between array and hash pattern behavior": arrays match only a **whole** array, while a hash pattern matches **even if there are other keys**. | Pattern | Subject | Matches? | Why | |---|---|---|---| | `[Integer, Integer]` | `[1, 2, 3]` | no | three elements, pattern accounts for two | | `[Integer, *]` | `[1, 2, 3]` | yes | `*` absorbs the rest | | `[first, *rest]` | `[1, 2, 3]` | yes | `first = 1`, `rest = [2, 3]` | | `{a: Integer}` | `{a: 1, b: 2}` | yes | extra `b:` is ignored | | `{a: Integer, **nil}` | `{a: 1, b: 2}` | no | `**nil` forbids other keys | | `{a: Integer, **rest}` | `{a: 1, b: 2}` | yes | `rest = {b: 2}` | | `{}` | `{a: 1}` | no | `{}` matches only an empty hash | | `{}` | `{}` | yes | the one exact-match hash pattern | The design follows how the data is used: positions in an array carry meaning, so an unexpected extra element usually means a different shape; hashes are records whose producers add fields over time, so ignoring unknown keys is the useful default. ## Why the lenient hash default suits webhooks A chat service that posts `{type: "message", text: "hi", ts: 1700000000}` today may add `thread_ts` and `blocks` next month. A router written as ```ruby case event in {type: "message", text:} handle_message(text) end ``` keeps working when fields are added. If you *do* need to reject unknown fields — say a strict internal command format — write `**nil`. ## Rest patterns bind the leftovers - `*rest` in an array pattern binds an **Array** of the remaining elements (possibly empty). - `**rest` in a hash pattern binds a **Hash** of the keys not named in the pattern. - A bare `*` or `**` accepts leftovers without binding them. ```ruby case event in {type: "message", text:, **meta} store(text, meta) # meta holds every other key end ``` ## Hash keys must be Symbols Hash patterns support **only Symbol keys**; the reference says so directly, and the parser expects a label (`type:` or `"type":`) as each key. Writing `in {"type" => "message"}` is a syntax error. The practical consequence: ```ruby JSON.parse('{"type":"ping"}') # {"type" => "ping"} JSON.parse('{"type":"ping"}') in {type: "ping"} # => false JSON.parse('{"type":"ping"}', symbolize_names: true) in {type: "ping"} # => true ``` The first parse produces String keys, so the pattern looks for `:type`, does not find it and fails — silently with `in`, with an error from `=>` or an exhaustive `case/in`. Parse webhook bodies with `symbolize_names: true` (or transform the keys) before matching. ## Key-only entries bind In a hash pattern, `text:` with no sub-pattern means two things at once: 1. the key `:text` must be **present**; 2. its value is bound to a local named `text`. Adding a sub-pattern keeps the check but stops the automatic binding: `text: String` requires a String and binds nothing, while `text: String => body` checks and binds to `body`. A key whose name is not a valid local variable cannot be used in key-only form. ## Nesting Both kinds nest freely: `{user: {id: String => uid}, attachments: [{kind: "image"}, *]}` checks the user id's type, binds it, and requires the attachments array to start with an image. Every nested array pattern is still whole-array; every nested hash pattern is still subset-of-keys. ## Summary - Arrays: every element must be matched; use `*`/`*rest` for "and more". - Hashes: only named keys are checked; use `**nil` for "and nothing else", `**rest` to capture extras. - `{}` matches only `{}`. - Only Symbol keys; parse JSON with `symbolize_names: true`.

  • Why does `JSON.parse(body) in {type: "ping"}` return false for a ping payload?
    `JSON.parse` returns String keys by default, and hash patterns only match Symbol keys, so the pattern looks for `:type` and does not find it. Parse with `JSON.parse(body, symbolize_names: true)` or convert the keys before matching. The `in` form reports this as `false`; `=>` would raise.
  • What is the difference between `in {text:}` and `in {text: String}`?
    Both require the `:text` key. The key-only form `text:` also binds its value to a local named `text`. With a sub-pattern, `text: String` additionally checks the value with `String === value` but binds nothing; write `text: String => text` to check and bind.

saying these in an interview costs you the question

  • A hash pattern fails if the hash has keys the pattern does not mention.
  • [Integer, Integer] matches [1, 2, 3] because the first two elements fit.
  • The pattern {} matches any hash, since it names no required keys.
  • Hash patterns match String keys too, so JSON.parse output works without options.
  • **rest binds an Array of the remaining values.