Trying out Sorbet, a ruby typechecker
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!