Pattern matching in Ruby is not just for arrays and hashes, it can be used for custom objects too.

Ever since playing around with functional languages like Haskell and Elixir, I was fascinated by the elegance of pattern matching.

Ruby 2.7 introduced experimental support for pattern matching and it was improved in later versions. It is amazing to see how these concepts transcend over programming paradigms, so let us explore it in an object oriented language.

Basic objects

For starters we can match over basic data types like arrays and hashes:

(I am using the rightward assignment => syntax in this blog post for brevity)

1 => Integer
"foo" => String
[1, 2, 3] => Array
[1, 2, 3] => [a,b,c] # assigns values a = 1, b = 2, c = 3
{ a: 10, b: 20 } => { a:, b: } # assigns a = 10, b = 20

And raises error when not matched:

1 => String
# String === 1 does not return true (NoMatchingPatternError)

[1, 2] => [x, y, z]
# [1, 2] length mismatch (given 2, expected 3) (NoMatchingPatternError)

{ a: 100, b: 200 } => { a:, x:, y: }
# key not found: :x (NoMatchingPatternKeyError)

Custom objects

The interesting part comes when matching non-primitive objects. You can use [] or () to match custom objects, extract and assign variables:

User = Data.define(:name, :email)
u = User.new("Sam", "sam@example.com")

u => User(name, email) # Matches and assigns variables name = "Sam" and email = "sam@example.com"

# And obviously fails when matching with a different class:
Account = Data.define(:name, :email)
u => Account(name, email)
# Account === #<data User ...> does not return true (NoMatchingPatternError)

Let us look at few more examples:

class Event
  attr_reader :name, :venue

  def initialize(name, venue)
    @name = name
    @venue = venue
  end
end

e = Event.new("Music Jam", "Orchird Road")
e => Event(name, venue)
#<Event:...> does not respond to #deconstruct (NoMatchingPatternError)

Pattern matching expects custom objects to define #deconstruct method. But why did it work with the Data objects above? Because Data class already has the method defined.

#deconstruct is expected to return an array:

# Opening the class to define this method
class Event
  def deconstruct
    [name, venue]
  end
end

e => Event(name, venue) # Works! Assigns name = "Music Jam" and venue = "Orchid Road"

How about keyword arguments?

class Book
  attr_reader :name, :published_year

  def initialize(name:, published_year:)
    @name = name
    @published_year = published_year
  end
end

b = Book.new(name: "Animal Farm", published_year: "1984")

b => Book[name:, published_year:]
#<Book:...> does not respond to #deconstruct_keys (NoMatchingPatternError)

We don't necessarily have to use hash-style syntax in pattern matching just because the initializer accepts keyword arguments.

b => Book[name, published_year]
#<Book:...> does not respond to #deconstruct (NoMatchingPatternError)

Note the subtle difference in error message - while the array-style pattern matching expects #deconstruct method, the hash-style expects #deconstruct_keys method.

#deconstruct_keys is expected to return a hash:

# Opening the class to define this method
class Book
  def deconstruct_keys(keys)
    { name:, published_year: }
  end

  def deconstruct
    [name, published_year]
  end
end

# Now both forms work!
b => Book[name:, published_year:]
b => Book[name, published_year]

You must've noticed the keys argument to #deconstruct_keys. Hash patterns (unlike arrays) also match subhash:

[1, 2, 3, 4] => [1, a]
# [1, 2, 3, 4] length mismatch (given 4, expected 2) (NoMatchingPatternError)

{a: 1, b: 2, c: 3, d: 4} => { a: } # Works and assigns a = 1

The keys used in pattern are passed to #deconstruct_keys which are available to create the resulting hash. This is useful when hash creation is expensive, we can calculate only the requested subhash.

Testing

Minitest offers assert_pattern and refute_pattern matchers for testing patterns which is especially useful for custom objects.

assert_pattern { b => Book[name:, published_year:] }
refute_pattern { e => Event(name:, venue:) }

PS: Pattern matching in Ruby is an evolving area. This blog post is based on the status of Ruby 3.4.2. Some things may change in future versions of Ruby.

All code from this blog post along with tests can be found in this executable gist.