Exploring pattern matching in Ruby 2.7

Ruby 2.7 landed with experimental support for pattern matching. I got to wondering, how is it implemented under the hood? Is it possible to implement a method on your own objects to hook into it? That's when a kind soul on Mastodon linked me to the original feature proposal and I saw this:

Screenshot showing the deconstruct method implemented on a Struct

Aha! Here's what I found playing around with it. Code is available at this repo.

If an object has a deconstruct method, it can be pattern matched, so long as that method returns an Array-like or Hash-like object, according to the original feature proposal. However, I found returning a Hash to yield an unsuccessful result. Consider the following example code:

class PatternMatch
  attr_reader :name, :values

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

class ArrayPatternMatch < PatternMatch
  def deconstruct
    [name, values]
  end
end

class HashPatternMatch < PatternMatch
  def deconstruct
    { name: name, values: values }
  end
end

With a Minitest file setup like so:

require 'minitest/autorun'
require 'minitest/pride'
require_relative './custom_pattern_match'

class CustomPatternMatchTest < Minitest::Test
  def setup
    @array_match = ArrayPatternMatch.new(
      name: 'Serra Allgood', 
      values: %w[ruby pattern matching]
    )
    @hash_match = HashPatternMatch.new(
      name: 'Serra Allgood', 
      values: %w[ruby pattern matching]
    )
    @raw_hash = { 
      name: 'Serra Allgood', 
      values: %w[ruby pattern matching] 
    }
  end
end

Attempting to pattern match with @hash_match always results in a NoMatchingPatternError, as demonstrated in the following tests:

def test_assignment_with_hash_deconstruct
  assert_raises NoMatchingPatternError do
    @hash_match in { name: name, values: values }
  end
end

def test_case_with_hash_deconstruct
  assert_raises NoMatchingPatternError do
    case @hash_match
    in { name:, values: [lang, *feature]}
      assert_equal 'Serra Allgood', name
    end
  end
end

Matching with a raw hash produces what you expect:

def test_assignment_with_raw_hash
  @raw_hash in { name:, values: [lang, *feature]}

  assert_equal 'Serra Allgood', name
  assert_equal 'ruby', lang
  assert_equal 'pattern matching', feature.join(' ')
end

def test_case_with_raw_hash
  case @raw_hash
  in { name:, values: [lang, *feature] }
    assert_equal 'Serra Allgood', name
    assert_equal 'ruby', lang
    assert_equal 'pattern matching', feature.join(' ')
  end
end

Matching with the custom @array_match does work though:

def test_assignment_with_array_deconstruct
  @array_match in [name, [lang, *feature]]

  assert_equal 'Serra Allgood', name
  assert_equal 'ruby', lang
  assert_equal 'pattern matching', feature.join(' ')
end

def test_case_with_array_deconstruct
  case @array_match
  in [name, values]
    assert_equal 'Serra Allgood', name
  else
    assert false
  end
end

I found this to be a little disappointing, because hash pattern matching seems to be much more flexible than array pattern matching; you can match on only a subset of keys. With array pattern matching, if you don't use a splat operator to catch extra items, an unbalanced match is no match.

I hope this exploration proves useful to you!