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.

It’s been six months since we started using Sorbet in earnest, so it seems like a fitting time for reflection.

TL;DR

Sorbet is great and I’m glad we added it. There is an occassional bit of awkwardness, but the benefits remain well worth it. I much prefer Rails with Sorbet on top.

Where Are We At?

Our original goal was to be able to refactor and develop with confidence on our “mature” Rails codebase. It had reached a size where it was hard to hold all the pieces in your head, and subsystems were stable enough that they wouldn’t need any dev time for months on end.

We hoped that adding types would…

  • surface existing subtle bugs,
  • prevent collateral damage when refactoring, and
  • allow us to move thoughtfully and not break things.

We added types incrementally, usually whenever we started working in a new area of the codebase.

We started with models, then moved on to service classes, internal libraries and background workers. We typed a handful of view helpers (which were surprisingly buggy) and mailers.

We still have not typed any controllers yet.

Sorbet Strictness% of files
ignore3%
false35%
true39%
strict16%
strong7%

Thinking in Types: Bug Squashing

Sorbet has changed the way we start to investigate bugs. Now, we start by looking to see if the code involved is strictly typed yet. If not, we’ll start by adding types to the code. The act of adding types is a great warm up for getting familiar with a section of code. It gives us a chance to reacquaint ourselves with our assumptions and the data types involved.

The process of adding types will often reveal the bug, somewhere around half the time.

If the bug isn’t revealed, learning that the types are ok lets us exclude a whole class of bugs from our bug hunt and sharpen our focus.

Thinking in Types: nil

A subtle but far-reaching change in my own thinking has been how nil now stands out as a wild and infectious value. The explicitness that Sorbet requires with nils (T.let and T.must) has made me think very carefully about what should be nullable. Since anything that is nullable now feels like it’s going to be more work (because it is), we avoid nullable values unless they actually make sense.

This null-avoidance has even spread to the schema database, where we’ve tightened up some sloppily defined column types.

There will always be plenty of nils in the code; we’re just more aware of them now.

(I’ve also gotten pretty enamoured with the concept of Maybe types, but that’s a story for a different day.)

Thinking in Types: Hashes => Structs

Passing around complex values like hashes is another area where Sorbet’s influence is felt. Defining the shapes of hashes in Sorbet isn’t amazing, so we’ve converted a good number to T::Structs to take advantage of all the type goodness.

So data structures like:

{ payment: ..., application_fee: ..., amount_to_refund_cents: 123_45 }

become the much more lovely…

class RefundResult < T::Struct
    const :payment, Payment
    const :application_fee, Stripe::ApplicationFee
    const :amount_to_refund_cents, Integer
end

We did some of this before Sorbet, but Sorbet just made it more compelling.

Thinking in Types: Arrays vs CollectionProxy

Before Sorbet, we’d treat arrays of ActiveRecord objects and ActiveRecord::Associations::CollectionProxy collections identically. In some ways this was really nice; after all one of the joys of Ruby and Rails is duck-typing and just iterating without a care in the world.

Sorbet makes us aware of the differences, and it’s sometimes being useful to be more explicit about these. Methods like #sum work pretty differently on Enumerable compared to CollectionProxy so its nice to know what we’re working with.

This change isn’t a pure net positive since it adds a level of strictness we don’t always need. I’d call this one more 50/50.

Tooling

The Sorbet tooling remains pretty good. Or rather, we’ve stuck with the tooling back from when we started and haven’t needed to move on.

Our stack is still just sorbet-rails, a little bit of sorbet-typed and the Sublime LSP plugin. We had always intended to move to tapioca since it seemed like The Right Way to do things, but sorbet-rails has given us everything that we need.

We don’t have any strict process for re-generating Sorbet’s hidden definitions. We update gem dependencies all the time (via Dependabot) and only rarely need to make any further updates. Sorbet continues to work just fine.

A few updates to sorbet-runtime have caused issues, but they’ve all been resolved by just waiting for the next weekly release.

Onward

We <3 Sorbet. It’s great, and it’s made working on old code feel much more secure.

The stock tooling has been enough for us to get plenty of benefits with minimal fuss and we’ll stick with it for the time being.

I expected to have to get more into the guts of things (and did a little bit) but Sorbet has been pleasantly, wonderfully boring.