Ruby 2.7 Experimental Features in Production: Pattern matching and numbered block args

At Monolist, we’re building the command center for engineers. We integrate with all the tools engineers use (code hosting, project management, alerting), and aggregate all their tasks in one place.

Most of our backend services are built using Ruby. The creator of Ruby, Matz, once said that “the goal of ruby is to make programmers happy.” We share this goal at Monolist. Ruby enables us to iterate quickly to deliver new integrations and features to our customers, programmers, faster. We are especially excited about Ruby 2.7, and we’re already running some of our less critical services on the preview build released earlier this summer.

In this blog post, we’ll talk about our favorite new (experimental) features of Ruby 2.7 in the few months we’ve been running it in production.

Pattern Matching

Pattern Matching is one of Ruby 2.7’s marquee features. Here’s a simple example:

item = { integration: :github, title: "Fix onboarding bug" }

case item
in integration: :github, title:
  puts "Found github pull_request: #{title}"
in integration: :asana, title:
  puts "Found asana task: #{title}"
else
  puts "Found other item"
end


# => Found github pull request: Fix onboarding bug

Pattern matching in Ruby is a little bit different than in other languages. In statically typed languages like
Rust or Haskell, pattern matching constructs enable the compiler to guarantee that they’re exhaustive. In other words, code that doesn’t handle every possible case won’t compile. In Ruby, if the pattern is not exhaustive, you’ll instead get an error at runtime, NoMatchingPattern. This isn’t as useful, but still better than a potentially hidden bug were we to use a conditional.

We’ve found instead that pattern matching’s most useful quality in Ruby is for destructuring deeply nested hashes. Consider the following (real but simplified) code we use to get a pull request’s last updated timestamp. If the pull request doesn’t have any completed build statuses, we use the time the pull request was created. Otherwise, we use the timestamp from the build status.

def pull_request_updated_at(pr)
  status = pr[:statuses]&.first

  if status && (status[:status] == "success" || status[:status] == "failure")
    return status[:created_at]
  end

  pr[:created_at]
end

Now let’s rewrite this code using pattern matching:

def pull_request_updated_at(pr)
  case pr
  in { statuses: [status: { status: "success" | "failure", created_at: updated_at }] }
    updated_at
  else
    pr[:created_at]
  end
end

Notice that with pattern matching, we can eliminate some of the nested conditionals and safe navigation from our first attempt. Additionally, the pattern matching version is easier to read, and easier to reason about. The patterns explicitly connote our assumptions about the shape of our data, assumptions that are otherwise hidden behind if statements.

Since Monolist relies heavily on third party API’s, we find ourselves often dealing with complex, nested data structures. Pattern matching has helped us be more explicit and deliberate in our handling of this data, reducing bugs, and making it easier for the next person to add functionality.

Numbered block arguments

At Monolist, we’re heavy users of blocks/procs and lambdas. We especially love Ruby’s fluent api with Enumerables, which enables us to write clean and readable code like follows:

# Get relevant pull requests for user by repository
pull_requests
  .select { |s| s.assignees.include?(user) }
  .select { |s| s.merged_at.nil? || s.closed_at.nil? }
  .reject { |s| s.author == user }
  .group_by { |s| s.repository.id }

It’s easy to tell what this code is doing. We’re filtering pull requests by assignee, then selecting only the open ones, removing any that the user created, and finally grouping by repository id. However, one annoyance we faced before was the constant need for arbitrary block param names in simple blocks. It’s obvious that the s in each block is referring to a pull request, but we still have to repeat our name every time. This pattern littered our codebase:

➜  monolist ag --stats "{ \|s\|" | ag " matches"
219 matches
133 files contained matches

This has been solved in Ruby 2.7:

# Get relevant pull requests for user by repository
pull_requests
  .select { @1.assignees.include?(user) }
  .select { @1.merged_at.nil? || s.closed_at.nil? }
  .reject { @1.author == user }
  .group_by { @1.repository.id }

Instead of using random characters (s, t, p) all over our codebase, we can reliably refer to block params by number. We think that this improves readability, and the general developer experience.

Monolist :heart: Ruby

At Monolist, we’re incredibly excited for the stable release of Ruby 2.7 later this year, Ruby 3 in the coming years, and beyond.


Are you a Ruby engineer that sometimes spends more time scanning tools for tasks and pull requests, than actually writing code? Monolist can help! Sign up for free here.