If you’re interested in seeing the code for this post it’s all here.

I’ve been trying out Sorbet, a type checker for ruby that recently became open source.

First steps

The first thing I did was add sorbet and the sorbet-runtime gem. This also installs the gem sorbet-static, a dependency of sorbet.

I then ran srb init. This took around 30 seconds to generate a sorbet directory containing 20 directories, 112 files. It also added # typed: true to 125 files and # typed: false to 108 files - a 53.64% success rate.

The majority of the failures were config files and specs. But there were some important files missed, too, including search_controller and finders_controller, and a couple of models. These files likely were skipped as they’re a bit complex (which is probably my bad). It also added typed: strong and typed: autogenerated to a few files (most of them within the newly created sorbet/ directory).

Once the init command completed, it printed a helpful suggestion on what to do next: srb tc which typechecks the project!

$ bundle exec srb tc
No errors! Great job.

Awesome!

Starting to check types

So what did srb tc actually do? From what I can gather, sorbet looks for constant resolution errors. I understand most of what is written about those errors in sorbet’s docs. Sorbet looks at every file except those with typed: ignore at the top. More in the docs.

It’ll catch things like NoMethodErrors before they turn into runtime errors, which is very cool.

For example, I added call_non_existant_method to the end of a method in the UrlBuilder class and the typechecker caught it. This would not have been caught unless this method had been called in a test, or a user had hit this.

$ bundle exec srb tc
app/lib/url_builder.rb:15: Method call_non_existant_method does not exist on UrlBuilder https://srb.help/7003
    15 |    call_non_existant_method
            ^^^^^^^^^^^^^^^^^^^^^^^^

I looked at the docs for adopting sorbet, and found this under running sorbet tc the first time:

Step 4: Fix constant resolution errors At this point, it’s likely that there are lots of errors in our project, but Sorbet silences them by default. Our next job is to unsilence them and then fix the root causes.

Later it says

Step 4 was the biggest hurdle to adopting Sorbet

So it looks like adopting sorbet is easier than it looks for finder-frontend. A caveat is that this is a fairly simple rails app.

Importantly, Sorbet does not yet report type errors The final step is to start enabling more type checks in our code

OK. So I’ll start adding typed: true and adding method signatures.

I’ll start with the CacheableRegistry module, as I worked on it recently.

Changing typed: false to typed: true in this file yields the following error.

bundle exec srb tc
app/lib/registries/cacheable_registry.rb:25: Method raise does not exist on Registries::CacheableRegistry https://srb.help/7003
    25 |      raise NotImplementedError, "Please supply a cacheable_data method"
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Interestingly, trying this out on sorbet.run does not yield the same thing.

To resolve this, I need to include Kernel in the module. I suppose this will be necessary on every class that calls raise. This seems a shame since the Kernel module is already included in every ruby Object class (cite), though I understand one can override methods like puts, raise, and sleep if you really wanted to.

A cool thing happened when I changed the CacheableRegistry to a class, when messing around with the raise issue. I got this error when running srb tc:

sorbet/rbi/hidden-definitions/hidden.rbi:24230: Registries::CacheableRegistry was previously defined as a class https://srb.help/4012
       24230 |module Registries::CacheableRegistry
       24231 |  extend ::T::Sig
       24232 |end

app/lib/registries/world_locations_registry.rb:3: Only modules can be included. This module or class includes Registries::CacheableRegistry https://srb.help/5032
     3 |  class WorldLocationsRegistry < Registry
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
<more errors>

This is great, since it tells me everywhere that I need to change the usage of this module if I do want to go ahead with changing it.

Stricter typing

While I’m looking at this module, I wonder if I can get it to be typed: strict.

Making that change throws some expected errors. Strict mode requires that all methods have sigs, and all variables must have a type.

$ bundle exec srb tc
app/lib/registries/cacheable_registry.rb:6: This function does not have a `sig` https://srb.help/7017
     6 |    def can_refresh_cache?
            ^^^^^^^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
...
<more errors>

But, that’s exciting, running it with the -a flag fixes half of them. Now the can_refresh_cache? method has a signature (sig) above it!

sig {returns(TrueClass)}
def can_refresh_cache?
  true
end

This is great. What about the other methods? The refresh_cache method should return a boolean, if we’ve been able to cache our data. At the moment we’re seeing an error in strict mode:

app/lib/registries/cacheable_registry.rb:10: This function does not have a `sig` https://srb.help/7017
    10 |    def refresh_cache
            ^^^^^^^^^^^^^^^^^

To fix this we can add T::Boolean as the return type in the sig.

sig {returns(T::Boolean)}
def refresh_cache
  Rails.cache.write(cache_key, cacheable_data)
rescue GdsApi::HTTPServerError, GdsApi::HTTPBadGateway
  report_error
  false
end

What’s quite nice is how much sorbet reveals about it’s internals as you work with it. T::Boolean, when recommended, shows you that it’s an alias.

T::Boolean = T.type_alias(T.any(TrueClass, FalseClass))

With a couple of other easy fixes, CacheableRegistry is now strictly typed.

This has some benefits, but you can read about those in the sigs docs.

In review

What I like about all of this is that it’s all valid ruby. It makes for a much simpler adoption.

At a previous organisation I worked on a couple of migration projects of a JS codebase: from CoffeeScript to ES6 transpiled Javascript, and from there to adopting TypeScript. Adopting TypeScript was quite tricky, as sections of the code needed to be rewritten into pretty much a different language. It was sometimes tricky to get components to be interoperable with other parts of the codebase.

With the route that the team behind sorbet have taken, it’s much easier to introduce type checks, without the pain of feeling like you’re performing a migration. It’s still a migration, but one that takes minutes, not months.

I like that the sorbet’d (?) code isn’t going to be transpiled into something different to what you’ve written. It’s all still valid ruby, just with some annotations. But they’re far superior to comments, as the static analyser sorbet tc ensures the annotations can’t become stale.

Sorbet is still a fairly young project, and I probably can’t introduce it into projects at work yet. That said, on GOV.UK we have our fair share of runtime errors that would be caught if we had a type checker. I’m quite excited about sorbet and the direction that it is headed, and will see if I can use sorbet in my own projects.

Good luck to the sorbet team!