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. If you've read our previous
blog posts, you know that 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? || @1.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 ❤️ 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. For more Ruby content, check out our first foray into adding types with Sorbet here, or the patterns we use to scale our API integrations here.
Top comments (1)
Needa correction
'''ruby
.select { @1.merged_at.nil? || s.closed_at.nil? }
'''