Deconstruct and Deconstruct_keys
When you write case value in pattern, Ruby doesn’t just peek at instance variables — it asks your object two very specific questions: “if I need you as an array, what are your parts?” and “if I need you as a hash, what are your named fields?” Those questions are answered by two ordinary instance methods you write yourself: deconstruct and deconstruct_keys. Implementing them is what turns any custom class — a Point, a Money, a domain object like Order — into something Ruby’s pattern matching can destructure just as naturally as it destructures arrays and hashes.
Overview: How Pattern Matching Talks to Your Objects
Ruby’s case/in pattern matching (stabilized in Ruby 3.0, refined through 3.2+) supports two families of structural patterns: array patterns like in [x, y] and hash patterns like in {x:, y:}. Arrays and hashes already know how to match these directly, but a plain object — an instance of a class you wrote — has no built-in notion of “my elements” or “my keys.” So Ruby defines a simple protocol: before trying to match an array pattern against a non-Array object, it checks whether the object responds to deconstruct, calls it, and matches the pattern against whatever Array comes back. Before matching a hash pattern against a non-Hash object, it checks for deconstruct_keys, calls it, and matches against the Hash that comes back.
This is exactly the same spirit as to_s for string conversion or each for enumerability: Ruby favors small, well-known protocol methods over baking special cases into the language. Any object that implements these two methods gets full pattern-matching support for free, including nested patterns, guard clauses, and the class-check shorthand ClassName(...). Under the hood, Array itself defines deconstruct as returning self, and Hash defines deconstruct_keys as returning self — which is why array and hash patterns “just work” without you writing anything. Structs generated with Struct.new get both methods generated automatically from their members, which is one reason Struct-based value objects are so pleasant to pattern-match against.
The two methods answer different structural questions and are used by different pattern shapes:
deconstruct— called for array patterns:in [a, b],in [a, *rest], find patterns likein [*, target, *], and the positional form of a class pattern,in Point(x, y).deconstruct_keys(keys)— called for hash patterns:in {a:, b:},in {a:, **rest}, and the keyword form of a class pattern,in Point(x:, y:).
Both a class pattern like Point(x, y) and Point(x:, y:) additionally check is_a?(Point) (more precisely, Point === value) before attempting to deconstruct, so you get a type check and a structural check in one line.
Syntax
class MyClass
def deconstruct
[value1, value2]
end
def deconstruct_keys(keys)
{ key1: value1, key2: value2 }
end
end
| Part | Meaning |
|---|---|
deconstruct |
Takes no arguments. Must return an Array. Powers array patterns and the positional form of class patterns. |
deconstruct_keys(keys) |
Takes exactly one argument. Must return a Hash. Powers hash patterns and the keyword form of class patterns. |
keys |
Either an Array of the Symbol keys the pattern is actually asking for, or nil when the pattern needs every key (for example when it uses **rest without a fixed key list). You’re free to ignore this parameter and always return the full hash — most real-world implementations do — but it lets you optimize expensive lookups when only specific keys were requested. |
You only need to implement the method(s) that match the pattern shapes you intend to support. A class that will only ever be matched with hash patterns doesn’t need deconstruct at all, and vice versa — though implementing both is common and cheap.
Examples
Example 1: Array patterns with deconstruct
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct
[x, y]
end
end
point = Point.new(3, 4)
case point
in [x, y]
puts \"Point at x=#{x}, y=#{y}\"
end
Point at x=3, y=4
The pattern [x, y] is an array pattern, so Ruby checks whether point responds to deconstruct. It does, so Ruby calls point.deconstruct, gets back [3, 4], and matches that array against [x, y] — binding x = 3 and y = 4 as local variables inside the in branch, exactly as if you’d written x, y = [3, 4].
Example 2: Hash patterns with deconstruct_keys
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct_keys(keys)
{ x: x, y: y }
end
end
point = Point.new(10, 20)
case point
in { x:, y: }
puts \"x is #{x}, y is #{y}\"
end
case point
in { x: 10 }
puts \"x equals 10, and the rest of the hash is ignored\"
end
x is 10, y is 20
x equals 10, and the rest of the hash is ignored
The shorthand { x:, y: } means “match a hash-like object that has keys :x and :y, and bind their values to local variables of the same name.” Ruby calls point.deconstruct_keys([:x, :y]), gets { x: 10, y: 20 }, and matches. Notice the second case: a hash pattern doesn’t have to mention every key the object has — { x: 10 } matches successfully even though y is present but unmentioned. This is different from array patterns, where [x] would not match a two-element array — array patterns are strict about length unless you use a splat like [x, *].
Example 3: Both together, with class patterns
class Success
attr_reader :value
def initialize(value)
@value = value
end
def deconstruct
[value]
end
def deconstruct_keys(keys)
{ value: value }
end
end
class Failure
attr_reader :error
def initialize(error)
@error = error
end
def deconstruct
[error]
end
def deconstruct_keys(keys)
{ error: error }
end
end
def handle(result)
case result
in Success(value)
\"Succeeded with #{value}\"
in Failure(error:)
\"Failed: #{error}\"
end
end
puts handle(Success.new(42))
puts handle(Failure.new(\"timeout\"))
Succeeded with 42
Failed: timeout
Success(value) is a class pattern with parentheses containing a bare identifier, so Ruby treats it as the positional (array) form: it first checks result.is_a?(Success), then calls deconstruct and matches [value] against the returned [42]. Failure(error:) uses the keyword form instead, because of the trailing colon — Ruby checks result.is_a?(Failure), then calls deconstruct_keys([:error]) and matches against { error: "timeout" }. This pattern — a small hierarchy of result-like objects matched with class patterns — is a common, expressive way to write branching logic in modern Ruby without a pile of if/elsif or manual is_a? checks.
How It Works Step by Step
When Ruby evaluates a case value; in pattern; ...; end expression, it works through the in clauses in order:
- Ruby looks at the shape of the current
inpattern: is it a literal, an array pattern, a hash pattern, a class pattern, a variable binding, or a combination via|and nesting? - If the pattern is an array pattern and
valueis not already anArray, Ruby checksvalue.respond_to?(:deconstruct). If false, this clause simply fails to match and Ruby moves to the nextin. If true, Ruby callsvalue.deconstructand uses the returned array for matching. - If the pattern is a hash pattern and
valueis not already aHash, Ruby checksvalue.respond_to?(:deconstruct_keys). If false, the clause fails to match. If true, Ruby callsvalue.deconstruct_keys(keys), wherekeysis the list of symbol keys the pattern references (ornilif the pattern needs everything), and uses the returned hash. - If the pattern includes a class check (
ClassName(...)or a bareClassName), Ruby first evaluatesClassName === valuebefore attempting deconstruction. If that fails, deconstruction never happens. - Ruby matches each element of the returned array (or each key/value of the returned hash) against the corresponding sub-pattern, recursively applying the same rules for nested patterns, and binds any plain identifiers as local variables as it goes.
- If every part matches, the clause’s body runs and the
caseexpression evaluates to whatever that body evaluates to — no furtherinclauses are tried. - If no
inclause matches and there’s noelse, Ruby raisesNoMatchingPatternError(or the more specificNoMatchingPatternKeyErrorfor a hash pattern that matched the object’s type but was missing a required key).
Two details worth internalizing: deconstruction only happens when it’s needed (an array pattern never triggers deconstruct_keys, and a hash pattern never triggers deconstruct), and it can happen more than once if you write multiple in clauses that each need it — Ruby doesn’t cache the result between clauses, so avoid doing expensive work inside these methods if a value might be matched against many patterns.
Common Mistakes
Mistake 1: Forgetting the keys parameter on deconstruct_keys
Ruby always calls deconstruct_keys with exactly one argument. Defining it with no parameters looks harmless until it’s actually used in a hash pattern:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct_keys
{ x: x, y: y }
end
end
case Point.new(1, 2)
in { x:, y: }
puts \"matched\"
end
ArgumentError: wrong number of arguments (given 1, expected 0)
Ruby tries to call deconstruct_keys([:x, :y]), but the method takes zero arguments, so it raises ArgumentError before matching ever happens. The fix is to always declare the parameter, even if you never use it:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct_keys(keys)
{ x: x, y: y }
end
end
case Point.new(1, 2)
in { x:, y: }
puts \"matched: x=#{x}, y=#{y}\"
end
matched: x=1, y=2
Mistake 2: Using a hash pattern on an object that only implements deconstruct
Array patterns and hash patterns are not interchangeable — a class that only defines deconstruct cannot be matched with a hash pattern, no matter how sensible that seems:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct
[x, y]
end
end
point = Point.new(5, 6)
case point
in { x:, y: }
puts \"x=#{x}, y=#{y}\"
end
NoMatchingPatternError (Point does not implement deconstruct_keys)
Since Point doesn’t respond to deconstruct_keys, the hash pattern silently fails to match, and because there’s no other in clause and no else, Ruby raises NoMatchingPatternError. The fix is to implement both methods whenever you want a class to support both pattern shapes:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct
[x, y]
end
def deconstruct_keys(keys)
{ x: x, y: y }
end
end
point = Point.new(5, 6)
case point
in { x:, y: }
puts \"x=#{x}, y=#{y}\"
end
x=5, y=6
A related trap: because array patterns check length strictly, if deconstruct returns a different number of elements than a pattern expects, the match fails too — for example [x, y] will not match a deconstruct result of [3, 4, 5] unless you write [x, y, *] to allow trailing elements.
Best Practices
- Implement both
deconstructanddeconstruct_keystogether for value-like objects (points, money, coordinates) so callers can use whichever pattern style reads better in context. - Keep both methods pure and cheap — no I/O, no mutation of the receiver — since Ruby may call them once per
inclause it tries. - Prefer
Struct.new(:x, :y, keyword_init: true)for simple value objects; it generates workingdeconstructanddeconstruct_keysfor you, so you only need to hand-write these methods when a plain class gives you more control (validation, computed fields, a richer API). - Always accept the
keysparameter indeconstruct_keys, even if you ignore it — Ruby’s call signature is fixed and won’t tolerate a zero-arity method. - Return a fresh
Hash/Arrayrather than an internal, mutable instance variable, so a caller pattern-matching on your object can’t accidentally end up with a live reference into your object’s private state. - Use class patterns (
ClassName(...)) instead of a bare array or hash pattern when you also want a type check — it reads clearly and avoids accidentally matching an unrelated object that happens to have the same shape. - Reach for
case/inand these deconstruction methods when you’re branching on the shape of data (a result type, a parsed token, an event payload); stick with plaincase/whenoriffor simple value or class comparisons.
Practice Exercises
- Write a
Rectangleclass withwidthandheight, implementdeconstruct_keys, and use acase/inhash pattern with a guard clause (in {width:, height:} if width == height) to print"It's a square!"versus"It's a rectangle." - Give a
Temperatureclass (storing degrees and a:celsius/:fahrenheitunit symbol) bothdeconstructanddeconstruct_keys, then match an array of severalTemperatureinstances, printing a converted Fahrenheit value whenever the unit is:celsius. Expected: for a 100-degree Celsius reading, your branch should compute and print 212.0. - Without running it, predict what
case [1, 2, 3]; in [Integer => a, *rest]; puts "a=#{a}, rest=#{rest}"; endprints, then verify by running it — this exercises array pattern matching directly on a built-inArray, wheredeconstructjust returnsself.
Summary
deconstructlets a custom object act like an array incase/inarray patterns; it takes no arguments and must return anArray.deconstruct_keys(keys)lets a custom object act like a hash incase/inhash patterns; it takes one argument (the requested keys, ornilfor all of them) and must return aHash.- Ruby only calls these methods when the matching pattern actually needs them — an array pattern never touches
deconstruct_keys, and a hash pattern never touchesdeconstruct. - Class patterns like
ClassName(...)combine a type check (is_a?) with deconstruction, giving you type-safe destructuring in one expression. Array,Hash, and generatedStructclasses already implement these methods, which is why they pattern-match out of the box.- A missing
keysparameter or a missing method entirely are the two most common reasons a hash or array pattern silently fails to match, or raises an error.
