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.
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.
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.
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.
gemspec resulted in 52 RBI files being brought in, including
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.
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/and
rm -rf sorbet/rbi/gems/(since we’re going to use
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 running
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
There were two errors we saw.
The first appears to be because of
psych being loaded as a gem:
firstname.lastname@example.org: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.
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
T methods as no-ops, so
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
(After going down this route we discovered
T::Sig::WithoutRuntime which looks like another useful tool for avoiding runtime checks in dependencies.)
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
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
Seats. In Sorbet that looks like:
Before Sorbet that served us really well. Duck-typing made things work nicely and we recursed by checking
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
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
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.
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
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.