loading...
Cover image for Find your way in Ruby Pattern matching

Find your way in Ruby Pattern matching

uryelah profile image uryelah ・6 min read

Last December we got for Christmas Ruby 2.7. release and between its many interesting features and improvements, one in specific is pretty much a first for the ruby language.

This article is an overview of it, ideal for ruby beginners curious about Ruby new features.

Pattern Matching

The experimental feature Pattern matching is, in the words of its creator, 'a combination of case/when and multiple assignments' and, if you are coming from Javascript, it might look like a quite lean switch statement.

That means we now can combine deconstruct arrays and hashes, and with matching its items with specific patterns or classes.

Pattern matching can be especially powerful when working with complex objects, like parsed JSON, YAML files, or even HTTP routers.

That all sounds very cool and all, but first let's play a bit with the basics of the feature.


Value pattern

Let's get used to the basic syntax.
It starts with the keyword case followed by the object or variable you will be matching.

case 67

Then we add the first matching case with the keyword in in the left.
In this one, we want to test if 67 is between 0 and 100.

case 67
  in 0..100

This is the only possibility we want to match for right now, so let's ask it to print something if it matches and close it with the end keyword.

case 67
  in 0..100
    p 'it\'s a match!'
end

It should output "It's a match" since the condition we matched for was true.

What if it's not the case though?
Let's test it again but with a number in the case that will fail the matching.

case 167
  in 0..100
    p 'it\'s a match!'
end
=> NoMatchingPatternError (167)

When the matching fails it outputs a NoMatchingPatternError followed by the tested object inside a parenthesis.

Let's handle when we don't have a match with an else.

case 167
  in 0..100
    p 'it\'s a match!'
  else
   p 'Number not in range :-('
end
=> "Number not in range :-("

That's way better than getting an error.

It's possible to add multiple matching conditions too.

case 80
  in 0..10
    p 'A small number'
  in 11..50
    p 'An okay sized number'
  in 51..100
    p 'A respectable sized number'
  else
   p 'Number not in range :-('
end
=> "A respectable sized number"

Variable pattern

What if we want to print the value that got matched itself?

That's when binding a variable to it comes in handy. Let's start simple.

case 100
  in num
    p "num got its name bound to #{num}"
end
=> "num got its name bound to 100"

And now we can combine the variable binding with the matching.

case 100
  in Integer => num 
    p "Bind #{num} only if matched"
end
=> "Bind 100 only if matched"

In the example above we first make sure that 100 is an Integer, and if that's true we bind its value to the variable num and print the message.


Alternative pattern

Sometimes more than one condition might be good.

make_it = 'better'

case make_it
  in 'harder' | 'better' | 'faster' | 'stronger' => like_this
    p "Do it #{like_this}"
end
=> "do it better"

As pattern

It's also possible to test regular expressions

bad_email = 'wrongmailATmail.com'

case bad_email
  in /.+@.+\.\w/ => email
    p "Sending to #{email}"
  else
    p 'This is not an email'
end
=> "This is not an email"
good_email = 'rightmail@mail.com'

case good_email
  in /.+@.+\.\w/ => email
    p "Sending to #{email}"
end
=> "Sending to rightmail@mail.com"

With Arrays

The real fun with Pattern matching starts when you use it with objects though. It's possible to match an array all the ways in the example below.

case [0, 1, 2]
  in Array(0, 1, 2) => arr
    p arr
end

case [0, 1, 2]
  in Object[0, 1, 2] => arr
    p arr
end

case [0, 1, 2]
  in [0, 1, 2] => arr
    p arr
end

=> [0, 1, 2]

Be careful with combining syntax sugar and variable binding though.

case [0, 1, 2]
  in 0, 1, 2 => arr
    p arr
end
=> 2

By default, arrays match exactly. The code bellow should fail the matching.

arr = [0, 1, 2]

case arr
  in [1, 2] => a
    p a
end
=> NoMatchingPatternError ([0, 1, 2])

If we don't care for the first item the array we can use _. Now it should only test the second and last array element and ignore the value of the first one.

arr = [0, 1, 2]

case arr
  in [_, 1, 2] => a
    p a
end
=> [0, 1, 2]

Or even better, we can match only a subset of the array by using *. Now it will only try to match the last array element with the number 2.

arr = [0, 1, 2]

case arr
  in [*, 2] => a
    p a
end
=> [0, 1, 2]

We can also bind variables to array elements.

user = [7271, ['Marcela', 'Pena', 26]]

case user
  in [id, [first_name, last_name, age]]
    p "User #{id}, #{first_name} #{last_name}, is #{age} years old"
end
=> "User 7271, Marcela Pena, is 26 years old"

Let's ignore some of the values with _, and use a guard clause to check if the user is of age.

user = [7271, ['Marcela', 'Pena', 26]]

case user
  in [_, [first_name, _, age]] if age >= 18
    p "#{first_name} is #{age} years old"
  else
    p "User is underage"
  end
end
=> "Marcela is 26 years old"


With hashes

my_hash = {a: 0, b: 1, c: 2}

case my_hash
  in Hash(a: a, b: 1) => h
   p h
end
=> {:a=>0, :b=>1, :c=>2}

case my_hash
  in Object[a: a, b: 1] => h
   p h
end
=> {:a=>0, :b=>1, :c=>2}

case my_hash
  in {a: a, b: 1} => h
   p h
end
=> {:a=>0, :b=>1, :c=>2}

# careful if you go no brackets though
case my_hash
  in a: a, b: b => h
   p h
end
=> 1

Hash patterns are more flexible than array matches, by default they always test for subsets.

my_hash = {a: 0, b: 1, c: 2}

case my_hash
  in {b: 1}
    p rest
end
=> [[0, 1, 2]]

case {a: 0, b: 1, c: 2}
  in a:, b:
    p a
    p b
end
=> 0
=> 1

It's possible to make exact matches with hashes though.

my_hash = {a: 0, b: 1, c: 2}

case my_hash
  in {a: 0, **rest} if rest.empty?
    p a
end
=>NoMatchingPatternError ({:a=>0, :b=>1, :c=>2})


other_hash = {a: 0}

case other_hash
  in {a: 0, **rest} if rest.empty?
    p a
end
=> 0

And power it up with deconstruction

user_hash = {id: 189, name: {first_name: 'Alanis', last_name: 'Morris'}, nick_name: nil, pets: {cats: ['Flufbell', 'Lady Merlin'], dogs: []}}

case user_hash
  in {id: nil => id, name: }
    # save user if it doesn't have an id and has a name
    p 'Save new user!'
  in {name:, nick_name: String => nick_name}
    p "#{nickname} is all set up"
  in {name: {first_name:, last_name:}, nick_name: String => nick_name, pets: {cats:, dogs:}} if cats.empty? && !dogs.empty?
    p "#{first_name} nick name will be #{last_name}-woof"
  in {name: {first_name:}, nick_name: nil => nick_name, pets: {cats:, dogs:}} if !cats.empty? && dogs.empty?
    p "#{first_name} nick name will be Catty#{first_name}"
  in {name: {first_name:}, nick_name: nil => nick_name, pets: {cats:, dogs:}} if !cats.empty? && !dogs.empty?
  p "#{first_name} nick name will be #{first_name}Meoof"
  else
    'Sign up'
  end
=> "Alanis nick name will be CattyAlanis"

Now that you got used to the basics of pattern matching and really want to go into the meat of it make sure to read the official documentation and other awesome in-depth articles written about it and to watch Kazuki Tsujimoto presenting it at RubyConf 2019.

Let me know if this article was useful for you or if it can be improved.
Happy codding!

Posted on by:

Discussion

markdown guide
 

Declarative programming for the win!

It seem powerful, thanks for writing about it.

It reminds me of pattern matching in Erlang and the sort of declarative matching Go does with JSON.

I wonder how faster/slower it'd be than checking stuff manually, this slide clearly shows how more direct the code looks - even though it might be slightly less readable as probably Rubysts are more accustomed to following conditional logic than these declarations. Especially "variable binding" and the "pin operator" :D

They also added "if/unless preconditions" which once again remind me of Erlang and its "guards".

I'm really, really excited about this! Hopefully it'll be stable for the next major release of Ruby and we can start using it :D

A tip: you can add syntax highlighting to your example by adding the language name after the triple backticks:

case 67
  in 0..100

was written as:

 

Thank you I didn't know that!
Yeah, I heard it's not that performative right now but hopefully it will be faster in the future versions.