As it’s a tradition, we got a new Ruby version on Christmas. This time we are getting pattern matching, a feature highly praised in other languages. After spending some time with Elixir last year I was curious how does Ruby’s pattern matching feel in the Ruby world and indeed how does it compare to Elixir’s?
Ruby 2.7 is adding a new case/in
syntax to support pattern matching. This also tells us, that this (so far experimental) feature is only available for our case
statements. Let’s look on some Ruby code and how Elixir case
statement makes it cleaner with pattern matching. In the first example we want to find a name of a parent called Alice and only as long as the kid is a single child. In the second we want to find the name of the parent of single child called Bob.
# Ruby
require 'json'
json = <<END
{
"name": "Alice",
"age": 30,
"children": [{ "name": "Bob", "age": 2 }]
}
END
# Finding the child
json = JSON.parse(json)
if json["name"] == "Alice"
children = json["children"]
if children&.size == 1 && name = children.dig(0, "name")
puts name
end
else
#...
end
# Finding the parent
if parent = json["name"]
children = json["children"]
if children&.size == 1 && children.dig(0, "name") == "Bob"
puts parent
end
else
#...
end
Thanks to the Ruby &.
operator and dig
method the code is not necessarily too long and we could even make the conditions a one-liner. We could also split it so that it’s actually a nice piece of code, but that’s not the point. The point is: can we do better with pattern matching?
# Elixir
json = ~s({"name": "Alice","age": 30,"children": [{ "name": "Bob", "age": 2 }]})
# Finding the child
case Jason.decode(json) do
{:ok, decoded} ->
case decoded do
%{"name" => "Alice", "children" => [%{"name" => name}]} ->
IO.inspect(name)
_ ->
IO.inspect("no match")
end
{:error, %Jason.DecodeError{} = error} ->
IO.inspect(error)
end
# Finding the parent
case Jason.decode(json) do
{:ok, decoded} ->
case decoded do
%{"name" => parent, "children" => [%{"name" => "Bob"}]} ->
IO.inspect(parent)
_ ->
IO.inspect("no match")
end
{:error, %Jason.DecodeError{} = error} ->
IO.inspect(error)
end
We could easily pattern matched on the parsed JSON and directly bind a nested value to a variable. Arrays are no problem either. We could easily combine the case
statements to pattern match on various conditions and still end up with very clear and succinct code.
There is no doubt that pattern matching helps us here. So what about Ruby 2.7?
# Ruby 2.7
require 'json'
json = <<END
{
"name": "Alice",
"age": 30,
"children": [{ "name": "Bob", "age": 2 }]
}
END
# Finding the child
case JSON.parse(json, symbolize_names: true)
in {name: "Alice", children: [{name: name}]}
puts name
else
# ...
end
# Finding the parent
case JSON.parse(json, symbolize_names: true)
in {name: parent, children: [{name: "Bob"}]}
puts parent
else
# ...
end
To pattern match on the parsed JSON we must use symbolize keys. Actually, we could have also used Jason.decode(json, keys: :atoms!)
to work with Elixir atoms, but this requires the atoms to exist as it calls to :erlang.binary_to_existing_atom
. Other than that we use the new case/in
syntax for any particular match. To actually match the Elixir code we would need a parse
method returning the error rather than throwing an exception. While this is possible in Ruby, most of the Ruby code is designed around catching exceptions.
Nevertheless it’s pretty good, because it still feels rubyish, and helped us to make the code more readable. Here is another example on matching a hash in Ruby 2.7:
# Ruby 2.7
case {a: 0, b: 1}
in Hash(a: a, b: 1)
# do something with a
in Object[a: a]
# do something with a
# or
in 0 | 1 | 2
# unreachable
in {a: 0, **rest} if rest.empty?
# unreachable
# check if it's empty
in {}
# unreachable
end
I would say that working with Ruby pattern matching feels quite similar to Elixir’s. So what’s different though? In Elixir pattern matching is not used just for a more readable case statement. It’s one of the most important control flow technique. Let’s look at the following function for marking an invoice as paid:
# Elixir
def mark_invoice_as_paid(invoice_id, paid_on) do
case DateTime.from_iso8601(paid_on) do
{:ok, paid_on, _} ->
case Accounting.pay_invoice(invoice_id, paid_on) do
%Invoice{} = _struct ->
:ok
{:error, reason} ->
{:error, reason}
end
_ ->
{:error, :invalid_paid_on}
end
end
case mark_invoice_as_paid(1, "1989-12-01") do
:ok -> IO.inspect "Paid!"
{:error, reason} -> IO.inspect "Invoice cannot be paid due to #{reason}"
end
Could we do something like this in Ruby 2.7? Structs could be our result tuples or we could simply got away with using arrays:
# Ruby 2.7
require 'date'
Result = Struct.new(:result, :error)
def parse_date(date)
Date.parse(date)
rescue Date::Error
nil
end
def mark_invoice_as_paid(invoice_id, paid_on)
case parse_date(paid_on)
in Date => day
# Business logic here
Result.new(:success)
# Or by using atom/array
:success
in nil
Result.new(:error, :invalid_paid_on)
# Or by using array
[:error, :invalid_paid_on]
end
end
# Pattern match on result object
case mark_invoice_as_paid(1, "1989-12-01")
in Result => r if r.result == :success
puts "Success!"
in Result => r if r.result == :error
puts r.error
end
# Pattern match on array
case mark_invoice_as_paid(1, "1989-12-01")
in :success
puts "Success!"
in :error, error
puts error
end
As we can see the first problem is that many methods we use in Ruby do not return nil
s for unsuccessful results and raises exceptions (similarly to parsing JSON example). We can fix that with a new method that would return nil
, :error
atom or some kind of result object. In mark_invoice_as_paid
definition we can see how we could pattern match on object class and in the usage of mark_invoice_as_paid
we can see how we could pattern match on a struct-based result object.
In case of matching on arrays we can omit the parentheses and the final case
statement looks pretty good! In reality we can achieve it with objects as well, but we have to implement deconstruct_keys
method which is not yet automatically provided in Ruby 2.7. Let’s see how this works when matching the date object. If we want to match on the year for example, we can map the year accessor as follows:
# Ruby 2.7
require 'date'
class Date
def deconstruct_keys(keys)
{ year: year }
end
end
def find_invoice_by_paid_on(paid_on)
case parse_date(paid_on)
in year: ..1989
# early return of no invoices before year 1989
in year: 1990..1992
# use different database for years 1990 to 1992
in year: 2000
# handle special case for year 2000
in Date => day
# fetch invoice based on the day
in nil
# error
end
end
I think this looks pretty neat. We could also apply it to our previous struct example, but nevertheless I feel that in places where I reach for returning results objects in Ruby I am pretty happy with my current pattern of:
# Ruby
Result = Struct.new(:result, :resource, :error) do
def success?
error.blank?
end
end
def example
if true
Result.new(:success, "some kind of result")
else
Result.new(:failure, nil, "some kind of error")
end
end
# And later on
result = example()
if result.success?
puts result.resource
else
puts result.error
end
I use this technique especially where I can chain more things like this together and pass some kind of resources or errors (and certainly not everywhere!). My sentiment is that if
statement works absolutely fine here. However, having a more complex scenario than success/error could benefit from a Ruby 2.7 case
statement with deconstruct_keys
definitions.
Another place where Elixir’s pattern matching shines is function definitions. Look at this:
defmodule DocumentController do
use Web, :controller
def create(conn, %{"data" => %{"type" => "documents", "attributes" => document_params}}) do
# create document
end
end
We were able to define a controller function that only matches on correct JSON:API spec format of the JSON sent and directly select the parameters of the document. This is especially great since we can do method overloading in Elixir. In Ruby we don’t have an equivalent.
So what’s the verdict? Pattern matching in Ruby still cannot compete with something designed around pattern matching from the ground up, but it’s great addition to Ruby. It does help with cleaning up code that works with messy hashes and arrays, possibly could help with other objects too, and quite importantly feels rubyish to me. This would be an awesome addition to the language, plus one from me.