#ruby

Pattern matching with Ruby

September 28, 2023

//

3 min read

With version 2.7, Ruby introduced a new language construct to work with data structures called pattern matching.

Pattern matching is a concept borrowed from functional programming. It enables developers to match values based on their structure (shape) and extract their elements at the same time. This powerful addition to the Ruby toolkit opens up new avenues for expressing complex logic in a more natural and elegant way.

In this article we will dive in and unlock the power of pattern matching to make your code easier to understand and reason about.

Your codebase will thank you!

Matching against array or hashes

In Ruby, pattern matching can be done using the in or => (only available in Ruby >= 3) operators. For the time being, destructering hashes is limited to those indexed by symbols (strings are not supported).

Below are a couple of examples illustrating the power of this approach:

# assuming api.get returns hashes like:
#   - { success: true|false, data: Object }
#   - { error: true, message: String }

case api.get('user', 1)
in { success: true, data: user }
  p user
in { success: false, message: error }
  p error
end

In this example, we use a case statement with the in operator to match different patterns within the returned hash from api.get method. Depending on the success field, we extract different values from the result.

If we only need to extract some value from the data (destructure), we can use => operator to avoid lengthy case / in construction.

result = { success: true }
result => { success: }
success # true

We can use both constructs to match against arrays as well:

# assuming result is similar to Rack compatible response
# (e.g. `[200, { "Content-Type": "text/plain" }, ["Hello World"] ]`)

case result
in [200, _, body]
  p body
in [302, { Location: url }, _]
  p url
end

Matching multiple patterns

You can also match against multiple patterns within a single block by using the | operator:

# assuming api.get might return hashes in one of the following schemas:
#   - { success: true|false, data: Object }
#   - { error: true, message: String }
#   - { error: true, data: String }

case api.get('user', 1)
in { success: true, data: user }
  p user
in { success: false, data: error } | { error: true, message: error }
  p error
end

Guards in pattern matching

Matchers can also include additional check that is verified after the value is matched:

# assuming api.get might return hashes in one of the following schemas:
#   - { status: 200, data: Object }
#   - { status: 201, data: Object }
#   - { status: 403, error: String }

case api.get('user', 1)
in { status: status, data: user } if (200...400).include?(status)
  p user
in { error: error }
  p error
end

Assigning matched values to variables

To assign the matched value from case / in construct to a variable (e.g. for further processing), combine it with the => operator:

case api.get('user', 1)
in { success: true|false, data: Object } => response
  p response[:data]
end

This allows you to work with the matched value conveniently by exposing the whole data under variable with the provided name (response in this case).

Matching the same value across patterns

To match the same value across different points of comparison, ese the ^ (pin operator):

case [api.count('user', 'active'), api.count('user', 'invited')]
in [number, ^number]
  puts "The same number of active users as invited"
else
  puts "Number are not the same"
end

Here, we ensure that the number in both positions of the array is the same.

Ignoring values

Sometimes, you might want to ignore specific values. You can use the underscore (_) for this purpose:

case api.get('user', 'active')
in [_, user]
  puts "A second active user is: #{user}"
end

Matching against classes

In Ruby, pattern matching isn’t limited to arrays and hashes. You can also utilize it with your own classes. To make a class available for pattern matching operators, you need to implement two methods:

  • deconstruct for array-style matching
  • deconstruct_key for hash-style matching

These methods allow you to specify how the class instance should be deconstructed when pattern matching is applied.

Here’s an example of a class named Response that implements these methods to make it pattern-matchable:

Response = Struct.new(:success, :result) do
  def deconstruct
    [success, result]
  end

  def deconstruct_key
    { success: success, result: result }
  end
end

In this Response class, we’ve defined the deconstruct method, which returns an array, and the deconstruct_key method, which returns a hash. These methods specify how an instance of the Response class should be deconstructed when pattern matched using array-style or hash-style patterns.

Let’s try the pattern matching with instances of our Response class:

response = Response.new(true, { id: 1, name: "Jon Snow" })

case response
in [true, user]
  p user
end

case response
in { success: true, result: user }
  p user
end

Pattern matching in Ruby is a powerful tool that simplifies data extraction and enhances code clarity. It’s a valuable addition to your Ruby toolkit, enabling you to write cleaner, and more expressive code.