Notes to self

Ruby 2.7 pattern matching after 10 months of professional Elixir

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 nils 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.

Check out my book
Deployment from Scratch is unique Linux book about web application deployment. Learn how deployment works from the first principles rather than YAML files of a specific tool.
by Josef Strzibny
RSS