ruby

An Introduction to Pattern Matching in Ruby

Pulkit Goyal

Pulkit Goyal on

An Introduction to Pattern Matching in Ruby

Let's start with a brief discussion about pattern matching in Ruby, what it does, and how it can help improve code readability.

If you are anything like me a few years ago, you might confuse it with pattern matching in Regex. Even a quick Google search of 'pattern matching' with no other context brings you content that's pretty close to that definition.

Formally, pattern matching is the process of checking any data (be it a sequence of characters, a series of tokens, a tuple, or anything else) against other data.

In terms of programming, depending on the capabilities of the language, this could mean any of the following:

  1. Matching against an expected data type
  2. Matching against an expected hash structure (e.g. presence of specific keys)
  3. Matching against an expected array length
  4. Assigning the matches (or a part of them) to some variables

My first foray into pattern matching was through Elixir. Elixir has first class support for pattern matching, so much so that the = operator is, in fact, the match operator, rather than simple assignment.

This means that in Elixir, the following is actually valid code:

1iex> x = 1
2iex> 1 = x

With that in mind, let's look at the new pattern matching support for Ruby 2.7+ and how we can use it to make our code more readable, starting from today.

Ruby Pattern Matching with case/in

Ruby supports pattern matching with a special case/in expression. The syntax is:

1case <expression>
2in <pattern1>
3  # ...
4in <pattern2>
5  # ...
6else
7  # ...
8end

This is not to be confused with the case/when expression. when and in branches cannot be mixed in a single case.

If you do not provide an else expression, any failing match will raise a NoMatchingPatternError.

Pattern Matching Arrays in Ruby

Pattern matching can be used to match arrays to pre-required structures against data types, lengths or values.

For example, all of the following are matches (note that only the first in will be evaluated, as case stops looking after the first match):

1case [1, 2, "Three"]
2in [Integer, Integer, String]
3  "matches"
4in [1, 2, "Three"]
5  "matches"
6in [Integer, *]
7  "matches" # because * is a spread operator that matches anything
8in [a, *]
9  "matches" # and the value of the variable a is now 1
10end

This type of pattern matching clause is very useful when you want to produce multiple signals from a method call.

In the Elixir world, this is frequently used when performing operations that could have both an :ok result and an :error result, for example, inserted into a database.

Here is how we can use it for better readability:

1def create
2  case save(model_params)
3  in [:ok, model]
4    render :json => model
5  in [:error, errors]
6    render :json => errors
7  end
8end
9
10# Somewhere in your code, e.g. inside a global helper or your model base class (with a different name).
11def save(attrs)
12  model = Model.new(attrs)
13  model.save ? [:ok, model] : [:error, model.errors]
14end

Pattern Matching Objects in Ruby

You can also match objects in Ruby to enforce a specific structure:

1case {a: 1, b: 2}
2in {a: Integer}
3  "matches" # By default, all object matches are partial
4in {a: Integer, **}
5  "matches" # and is same as {a: Integer}
6in {a: a}
7  "matches" # and the value of variable a is now 1
8in {a: Integer => a}
9  "matches" # and the value of variable a is now 1
10in {a: 1, b: b}
11  "matches" # and the value of variable b is now 2
12in {a: Integer, **nil}
13  "does not match" # This will match only if the object has a and no other keys
14end

This works great when imposing strong rules for matching against any params.

For example, if you are writing a fancy greeter, it could have the following (strongly opinionated) structure:

1def greet(hash = {})
2  case hash
3  in {greeting: greeting, first_name: first_name, last_name: last_name}
4    greet(greeting: greeting, name: "#{first_name} #{last_name}")
5  in {greeting: greeting, name: name}
6    puts "#{greeting}, #{name}"
7  in {name: name}
8    greet(greeting: "Hello", name: name)
9  in {greeting: greeting}
10    greet(greeting: greeting, name: "Anonymous")
11  else
12    greet(greeting: "Hello", name: "Anonymous")
13  end
14end
15
16greet # Hello, Anonymous
17greet(name: "John") # Hello, John
18greet(first_name: "John", last_name: "Doe") # Hello, John Doe
19greet(greeting: "Bonjour", first_name: "John", last_name: "Doe") # Bonjour, John Doe
20greet(greeting: "Bonjour") # Bonjour, Anonymous

Variable Binding and Pinning in Ruby

As we have seen in some of the above examples, pattern matching is really useful in assigning part of the patterns to arbitrary variables. This is called variable binding, and there are several ways we can bind to a variable:

  1. With a strong type match, e.g. in [Integer => a] or in {a: Integer => a}
  2. Without the type specification, e.g. in [a, 1, 2] or in {a: a}.
  3. Without the variable name, which defaults to using the key name, e.g. in {a:} will define a variable named a with the value at key a.
  4. Bind rest, e.g. in [Integer, *rest] or in {a: Integer, **rest}.

How, then, can we match when we want to use an existing variable as a sub-pattern? This is when we can use variable pinning with the ^ (pin) operator:

1a = 1
2case {a: 1, b: 2}
3in {a: ^a}
4  "matches"
5end

You can even use this when a variable is defined in a pattern itself, allowing you to write powerful patterns like this:

1case order
2in {billing_address: {city:}, shipping_address: {city: ^city}}
3  puts "both billing and shipping are to the same city"
4else
5  raise "both billing and shipping must be to the same city"
6end

One important quirk to mention with variable binding is that even if the pattern doesn't fully match, the variable will still have been bound. This can sometimes be useful.

But, in most cases, this could also be a cause of subtle bugs — so make sure that you don't rely on shadowed variable values that have been used inside a match. For example, in the following, you would expect the city to be "Amsterdam", but it would instead be "Berlin":

1city = "Amsterdam"
2order = {billing_address: {city: "Berlin"}, shipping_address: {city: "Zurich"}}
3case order
4in {billing_address: {city:}, shipping_address: {city: ^city}}
5  puts "both billing and shipping are to the same city"
6else
7  puts "both billing and shipping must be to the same city"
8end
9puts city # Berlin instead of Amsterdam

Matching Ruby's Custom Classes

You can implement some special methods to make custom classes pattern matching aware in Ruby.

For example, to pattern match a user against his first_name and last_name, we can define deconstruct_keys on the class:

1class User
2  def deconstruct_keys(keys)
3    {first_name: first_name, last_name: last_name}
4  end
5end
6
7case user
8in {first_name: "John"}
9  puts "Hey, John"
10end

The keys argument to deconstruct_keys contains the keys that have been requested in the pattern. This is a way for the receiver to provide only the required keys if computing all of them is expensive.

In the same way as deconstruct_keys, we could provide an implementation of deconstruct to allow objects to be pattern matched as an array. For example, let's say we have a Location class that has latitude and longitude. In addition to using deconstruct_keys to provide latitude and longitude keys, we could expose an array in the form of [latitude, longitude] as well:

1class Location
2  def deconstruct
3    [latitude, longitude]
4  end
5end
6
7case location
8in [Float => latitude, Float => longitude]
9  puts "#{latitude}, #{longitude}"
10end

Using Guards for Complex Patterns

If we have complex patterns that cannot be represented with regular pattern match operators, we can also use an if (or unless) statement to provide a guard for the match:

1case [1, 2]
2in [a, b] if b == a * 2
3  "matches"
4else
5  "no match"
6end

Pattern Matching with =>/in Without case

If you are on Ruby 3+, you have access to even more pattern matching magic. Starting from Ruby 3, pattern matching can be done in a single line without a case statement:

1[1, 2, "Three"] => [Integer => one, two, String => three]
2puts one # 1
3puts two # 2
4puts three # Three
5
6# Same as above
7[1, 2, "Three"] in [Integer => one, two, String => three]

Given that the above syntax does not have an else clause, it is most useful when the data structure is known beforehand.

As an example, this pattern could fit well inside a base controller that allows only admin users:

1class AdminController < AuthenticatedController
2  before_action :verify_admin
3
4  private
5
6  def verify_admin
7    Current.user => {role: :admin}
8  rescue NoMatchingPatternError
9    raise NotAllowedError
10  end
11end

Pattern Matching in Ruby: Watch This Space

At first, pattern matching can feel a bit strange to grasp. To some, it might feel like glorified object/array deconstruction.

But if the popularity of Elixir is any indication, pattern matching is a great tool to have in your arsenal. Having first-hand experience using it on Elixir, I can confirm that it is hard to live without once you get used to it.

If you are on Ruby 2.7, pattern matching (with case/in) is still experimental. With Ruby 3, case/in has moved to stable while the newly introduced single-line pattern matching expressions are experimental. Warnings can be turned off with Warning[:experimental] = false in code or -W:no-experimental command-line key.

Even though pattern matching in Ruby is still in its early stages, I hope you've found this introduction useful and that you're as excited as I am about future developments to come!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Share this article

RSS
Pulkit Goyal

Pulkit Goyal

Our guest author Pulkit is a senior full-stack engineer and consultant. In his free time, he writes about his experiences on his blog.

All articles by Pulkit Goyal

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps