Pattern Matching on Ruby Objects

A jigsaw puzzle of a Ruby gem floating through a field of numbers and patterns.
Image by Annie Ruygt

Ruby pattern matching landed in 2.7 via the case … in… statement. It’s a very powerful control structure the can make Ruby code cleaner and easier to read.

In this article we’ll explore the basics of how pattern matching can make your Ruby code more readable, then look at how you can implement it on your very own Ruby objects, like an ActiveRecord model or a Ruby class.

The basics of Ruby pattern matching

Let’s say you have a method that accepts different hashes and you want to pull data out of certain keys depending on the shape of the data. With pattern matching you can write code that looks like this:

def extract(**data)
  case data
    in name: {first:}
      puts first
    in tags: [first_tag, *_]
      puts first_tag
  end
end

Now let’s call it with some data and see what we get:

> extract(name: { first: "Brad", last: "Gessler" })
"Brad"
> extract(tags: ["person", "earthling"] })
"person"
> extract(name: { first: "Brad", last: "Gessler" }, tags: ["person", "earthling"] })
"Brad"

Pattern matching makes it possible to write concise, expressive code against data structures in Ruby.

Before pattern matching was released the code above would have had to be written as a complex if... else... statement that does lots of checks on the shape of the data.

def matcher(**data)
  if first = data.fetch(:first)
    puts first
  elsif data.key?(:tags)
    tags = data.fetch(:tags)
    if tags.is_a? Array
      puts tags.first
    end
  end
end

The more complex the data structure, there more conditionals that were needed to check for key existence, data types, etc., which can make code harder to read and more prone to bugs. Thankfully we now have the option of using pattern matching when writing code that checks the “shape” of data in Ruby.

Implementing matchers on your own Ruby classes

All the examples above use pattern matching against arrays and hashes, but not everything in Ruby is arrays in hashes. How then do we implement pattern matching capabilities on Ruby objects?

Ruby makes it easy to implement pattern matching in your own Ruby gems and code with the desconstruct_keys and deconstruct methods. The deconstruct_keys method returns a Hash object that allows the case statement to match on the hashes key structure while deconstruct returns an Array that can also be used to match an array of values.

Let’s build an HTML request router that uses both to understand when to use each method and how they’re different.

Consider the following HTTP request object. In this example it accepts a path, like /blogs/1/posts, a scheme, like https, and a form, like html.

class Request < Data.define(:path, :scheme, :format)
  def deconstruct_keys(*)
    { path: @path, scheme: @scheme, format: @format }
  end

  def deconstruct(*)
    path.split("/").compact
  end
end

Now lets create a request and run it through a router that’s defined with a case … in statement.

request = Request.new(path: "/blogs/1/posts",scheme: "http")
case request
  in "blog", blog_id, "posts"
    puts "Blog ID is #{blog_id}"
  in scheme: "http"
    raise "well that's not very secure!"
end

The first case … in statement matches on the array of path segments returned by the deconstruct method. In our case, the deconstruct method splits the request path string from "/blogs/1/posts" into an array ["blog", "1", "posts"] that we match in the cast statement to extract the blog_id.

The second case … in statement provides the scheme: "http" expression, which calls the deconstruct_keys method and matches the value "http" for the scheme key. In this case a match on scheme: "http" raises an exception.

Conditional patterns

Since deconstruct and deconstruct_keys are just methods on a class, we can have it return different arrays or hashes depending on the state of the class. Let’s add a basic response handler to our example that we can call from the request, depending on the requested format.

class Request
  attr_accessor :path, :scheme, :format

  def initialize(path: , scheme: "https", format: "html")
    @path = path
    @scheme = scheme
    @format = format.to_sym
  end

  def html(response) = puts "<p>#{response}</p>"
  def json(response) = puts  %({"data": #{response.to_s.inspect}})

  def deconstruct_keys(*)
    { path: @path, scheme: @scheme, format: @format }.merge(deconstruct_format)
  end

  def deconstruct(*)
    path.split("/").compact
  end

  def formatter
    self.method(@format)
  end

  def deconstruct_format
    Hash[@format, formatter]
  end
end

Let’s implement a server that processes the requests and responds to the request for a given format:

def process(request)
  case request
    in html:
      html.call "This should be HTML"
    in json:
      json.call "This should be JSON"
  end
end

process Request.new(path: "/blogs/1/posts", format: "json")
# "<p>This should be HTML</p>"
process Request.new(path: "/blogs/1/posts", format: "html")
# "{data: "This should be JSON"}"

How did we do that!? When the @format = "html" the deconstruct_format method:

  def deconstruct_format
    Hash[@format, formatter]
  end

A hash is created that looks like { html: self.method(:html) }, which pattern matches on the in html: block in the case statement:

  case request
    in html:
      html.call "This should be HTML"

That binds the def def html method to the html variable in the statement, which we can then call via html.call and prints "<p>This should be HTML</p>".

Wrap up

Pattern matching makes it possible to build even more types of expressive domain specific languages in Ruby that can result in less code. In our examples we created a rudimentary HTTP routing system from a Ruby request object, but we only scratched the surface of Ruby’s pattern matching capabilities.

Here’s a few ideas for how you could use it in your Ruby or Rails applications:

  • Permissions, access control, and authorization - Imagine all the conditionals you’d have to write for an access control object that checks the role on a user, the resources they can access, and the permissions they have on that object. Pattern matching would clean that up making it more understandable.
  • HTTP, parameters, middleware, and request/response objects - In our example above, we used pattern matching against a rudimentary HTTP request object. The same technique could be used within Rack middleware or Rails applications to route requests.
  • Interact with JSON APIs - Some APIs return JSON objects that are “different shapes” or deeply nested. Extracting this data can sometimes take lots of explicit conditionals that check if a key is present before accessing it. Pattern matching will handle all of that checking for you implicitly making it easier for others to follow the intent of the code.

It’s definitely worth 15 minutes of your time reading carefully through the Ruby pattern matching documentation at https://docs.ruby-lang.org/en/master/syntax/patternmatchingrdoc.html if you’re interested in writing more concise expressive Ruby code. It covers much more than this article including variable pinning, guard clauses, variable binding, and more. There’s even a handy cheatsheet that includes all the parts of Ruby pattern matching that’s a great reference once you’ve mastered pattern matching techniques.