Sorbet Journey, Part 2: Adding Sorbet to an Existing Ruby Gem
Sorbet Journey
This is an ongoing series about adding Sorbet to a mature Rails code base. Follow me on Twitter to find out when updates are posted.
What’s Happening
We chose a super simple gem to use to get a feel for Sorbet in the real world: tickit-best-seats
. This gem contains our seat selection algorithm for chosing the “best available” seats when you buy tickets for an event, following all sorts of business rules. We chose it because it is largely self-contained, it only uses the Ruby standard library and it works on simple, well-defined data structures.
TL;DR
Overall, adding Sorbet was an overwhelmingly positive experience. We caught some subtle bugs, tightened up some sloppy data structures and added a lot of safety guarantees. Some of Sorbet’s current limitations (e.g. lack of splat support) forced a little bit of awkward rewriting. All in all, the upsides – for this small library – far outweighed the downsides.
The Steps
Here are a few notes about the process of getting Sorbet wired up.
Step: Cleaning Up Dependencies
Since Sorbet is going to need to create RBI
files for any dependencies I wanted to keep things as clean as possible. Our gem hadn’t had any active development for about two years, so it was good to get freshened up anyways.
Almost all the dependencies for this gem are development only. It uses juwelier
for gem packaging, which unfortunately brings in a lot of other sub-dependencies which ended up causing some issues.
Our final gemspec
…
This modest gemspec
resulted in 52 RBI files being brought in, including nokogiri
, pry
, hashie
and a bunch of other things we don’t use directly. It’s understandable that they’re needed, but all the dependent RBIs add visual and mental clutter in git and our editors.
In these early days it was helpful to be able to grep these files. In future it might be possible to exclude them from our editor config, but they’ll always be in our repositories.
Step: Add rubocop-sorbet
and sigils
Shopify’s rubocop-sorbet gem was helpful to catch a few things before actually bringing in Sorbet. Our gem is so simple that it didn’t catch much. The main change was to add all the # typed: false
sigils to each file.
Adding the sigils creates a big messy commit. It wasn’t until later that I discovered the default-strictness
option that was added to Sorbet. It lets you run srb init
without having to touch every single Ruby file. Needing to touch every single Ruby file to get started with Sorbet is a definite turn off. Promoting using default-strictness
might be a good way to make the first-run experience better.
Step: Wire up Sorbet
After seeing Shopify’s talk we were convinced to use tapioca
instead of srb rbi
for gem RBIs and it worked pretty well. The basic steps were:
- Comment-out a glitchy gem (see below)
bundle exec srb init
rm -rf sorbet/rbi/sorbet-typed/
andrm -rf sorbet/rbi/gems/
(since we’re going to usetapioca
)bundle exec tapioca generate
(create all the actual gem RBIs that we will use)bundle exec srb rbi hidden-definitions
(since these will have changed after runningtapioca
)
There was one rough edge with srb init
. The juwelier
gem is old and has some problematic dependencies. We spent the better part of a day trying to chase down answers before realizing it was easier to just comment it out before running srb init
.
There were two errors we saw.
The first appears to be because of psych
being loaded as a gem:
sorbet/rbi/gems/psych@3.2.0.rbi:223: Method Psych::Nodes::Node#to_ruby redefined without matching argument count. Expected: 0, got: 2 https://srb.help/4010
223 | def to_ruby(symbolize_names: T.unsafe(nil), freeze: T.unsafe(nil)); end
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
https://github.com/sorbet/sorbet/tree/3a01f6f45b1760e255239166e76db90c33690bfb/rbi/stdlib/psych.rbi#L875: Previous definition
875 | def to_ruby(); end
psych
is in the Ruby standard library, but srb init
attempted to create separate gem definitions, causing these Previous definition
errors.
The second was about a namespace already being defined (which we never bothered to diagnose further):
.../gems/github_api-0.19.0/lib/github_api/api.rb:328:in `namespace': namespace 'say' is already defined (ArgumentError)
Commenting out the gem was thankfully easy in this case. If it hadn’t been we would likely have either forked our glitchy dependencies or chosen new ones.
Step: Configure sorbet-runtime-stub
We only wanted Sorbet to strictly enforce types during development while we’re still evaluating the toolchain. Shopify’s sorbet-runtime-stub library let’s that happen. It stubs out all of the sorbet-runtime
T
methods as no-ops, so T.cast
and T.must
, for example, don’t have any effect.
We only want it to run these stubs in the released “production” gem so we add this to the top of our main library code:
In order to still get the sorbet-runtime
checks in development you can require 'sorbet-runtime'
in your Rakefile
and test_helper.rb
.
(After going down this route we discovered T::Sig::WithoutRuntime
which looks like another useful tool for avoiding runtime checks in dependencies.)
Code Changes
Here are some of the bits of code that actually changed as a result of adding Sorbet.
Data Structure Improvements
The seat search results are passed around as an array like [SearchScore, [SeatObject, SeatObject, SeatObject]]
. The score
could be false
, nil
, Integer
or Float
depending on the context.
This was never ideal, but defining Sorbet’s types made it plain just how silly it was. No one wants to write T.nilable(T.any(T::Boolean, Integer, Float))
all the time. Instead we used a non-nullable float, using the much more elegant -Float::INFINITY
for “no results”. Our data structures got tighter and we got to remove some conditionals at the same time.
More Explicit Constructors
While it wasn’t strictly necessary, we ended up being much more explicit with all of our initializers, even when added typed: true
strictness where typed initializers are not actually required.
Adding these types added a bunch of ceremony to the top of each class, but the benefits of explicit types everywhere seemed to be worth it.
Before (Quick Class Initialization)
After (More Explicit Class Initialization)
Polymorphism Is Not As Fun
Our seat maps are made of SeatContainer
objects with contain either more SeatContainer
objects, or actual Seat
s. In Sorbet that looks like:
Before Sorbet that served us really well. Duck-typing made things work nicely and we recursed by checking parent?
and children?
methods quite elegantly.
After our first pass with Sorbet this wasn’t working well; Sorbet really wanted to know if it was either a SeatContainer
or a Seat
. The fastest way forward (remembering this is still our proof-of-concept) was to add lookup methods that filtered and cast the child elements before returning the results, e.g.
Pretty clunky. I’m sure that Sorbet’s abstract types would help us out, but we didn’t get that far during our first experiments.
Splats with Uknown Array Sizes
This might be a super specific limitation to our codebase, but I think illustrates the pitfalls of using a still-in-development toolchain. We used to call values_at(*some_indexes)
but Sorbet does not support splats with variable numbers of arguments. So we had to do some rewriting:
Creating our Gem’s RBI File with Parlour
Since we are already doing all the type definition work it seemed our final step should be to generate our own RBI files to ship with the gem. Otherwise, when we use our gem we’d be relying on tapioca
or hidden-definitions
when we really shouldn’t need to.
parlour
seemed to be the tool we needed. I was hesitant to add more dependencies and it wasn’t clear what its role is in the Sorbet ecosystem. parlour
is used heavily by tapioca
and sorbet-rails
and has received PRs from Shopify so it’s sort-of blessed. Blessed enough for us, at least.
It was super easy to run: we created a .parlour
file and ran bundle exec parlour
and it was done.
Including RBI files with gems is pretty lightly documented and still feels like a work in progress. We ran into a few issues once we started using the gem in the Rails app, but we’ll save those for the next post.
Wrapping Up
Starting our Sorbet journey with an isolated gem was a useful excercise. It got us comfortable with Sorbet syntax and concepts (e.g. T.must
for nil checks) without needing to wrangle an entire Rails meta-programmed monolith. It also let us experiment with different parts of the toolchain (e.g. srb rbi
vs tapioca
).
Sorbet checks are now a required step in this gem’s CI test suite and the Sorbet-ified version of our gem is now live. We’ve already started applying the lessons learned here to our main app. Stay tuned for that post.
I'd love to hear any feedback you might have about this post. Tweet me up at @mrmrbug or email me at code@dunae.ca.
You can also grab a little bit of RSS or check out the rest of the blog.