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

s.add_runtime_dependency('sorbet-runtime-stub')
s.add_development_dependency('amazing_print')
s.add_development_dependency('benchmark-ips')
s.add_development_dependency('bundler')
s.add_development_dependency('juwelier')
s.add_development_dependency('minitest')
s.add_development_dependency('parlour')
s.add_development_dependency('rubocop')
s.add_development_dependency('rubocop-performance')
s.add_development_dependency('rubocop-sorbet')
s.add_development_dependency('ruby-prof')
s.add_development_dependency('sorbet')
s.add_development_dependency('sorbet-runtime')
s.add_development_dependency('tapioca')

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:

  1. Comment-out a glitchy gem (see below)
  2. bundle exec srb init
  3. rm -rf sorbet/rbi/sorbet-typed/ and rm -rf sorbet/rbi/gems/ (since we’re going to use tapioca)
  4. bundle exec tapioca generate (create all the actual gem RBIs that we will use)
  5. bundle exec srb rbi hidden-definitions (since these will have changed after running tapioca)

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:

require 'sorbet-runtime-stub' unless defined?(T)

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.

-  NO_RESULTS = [false, []].freeze
+  NO_RESULTS = [-Float::INFINITY, []].freeze

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)

class Seat
    attr_reader :seat_id, :parent

    def initialize(seat_id, parent)
        @seat_id = seat_id
        parent = parent
    end
end

After (More Explicit Class Initialization)

class Seat
    sig { returns(String) }
    attr_reader :seat_id

    sig { returns(T.nilable(SeatContainer)) }
    attr_reader :parent

    sig do
       params(
         seat_id: String,
         parent:  T.nilable(SeatContainer)
       ).void
     end
    def initialize(seat_id, parent)
        @seat_id = seat_id
        parent = parent
    end
end

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:

class SeatContainer
    sig { returns(T::Array[T.any(SeatContainer, Seat)) }
    attr_reader :children
end

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.

class SeatContainer
    sig { returns(T.nilable(Seat)) }
    def seat_children(child_index)
        return unless is_seat_container?
        T.cast(children[child_index], Seat)
    end
end

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:

-    seats = children.values_at(*chunk.indexes)
+    seats = seats_at_indexes(chunk.indexes)
+
+    sig { params(indexes: T::Array[Integer]).returns(T::Array[Seat]) }
+    def seats_at_indexes(indexes)
+      indexes.map do |index|
+        children[index]
+      end.compact
+    end

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.