Static type checking in Rails with Sorbet
At CZI, we developed a personalized learning application, the Summit Learning platform, using the Ruby on Rails framework. In the early days, Rails enabled us to build out the platform quickly. However, now that we have 30+ engineers and 2,500+ files of Ruby code written over 6 years, we’ve found it harder to iterate. To help us move faster together, we decided to adopt Sorbet, a fast and powerful static type-checking tool for Ruby.
In this post series, we’d like to share our journey adopting Sorbet: the challenges and the lessons we learned from tackling those challenges. By reading these posts, you’ll learn the basics of using Sorbet to type-check an existing Rails app, and you’ll be equipped to drive the adoption of Sorbet on your team.
WHAT IS SORBET?
Sorbet is a Ruby static type checker developed by Stripe. Ruby is an interpreted language; objects and method calls are evaluated at run-time without any compilation step. If you attempt to call a nonexistent method on a Ruby object, Ruby cannot tell you in advance it’d be a problem. Instead, you’d get a
NoMethodError when the code is executed:
Statically typed languages like Java or C would be able to flag that this method doesn’t exist on an array. Sorbet brings this functionality to Ruby code: it can perform checks on code and flag this error without running the code. This process is referred to as static type checking.
Sorbet can bring a lot of good things to the developer experience: detect errors early, improve code quality, and sometimes flag dead code. To accomplish this, it relies on knowing object types and method signatures (
sig) in the codebase. It already provides method signatures for Ruby’s core API, and users can define signatures for any custom method.
You probably can already foresee the challenge with adopting Sorbet at this point: will it have signatures for the framework and the gems that I use? Will I have to write signatures for all methods in my codebase? Worry not — Sorbet is meant to be adopted gradually. It can yield benefits with little adoption effort, but the more effort you put into it, the more value it will bring. Plus, we’ve already solved a lot of the challenges associated with adopting it in a Rails app.
USING SORBET WITH RAILS
Ruby on Rails poses many technical challenges to integrate static type checking. We’ve devised solutions for a number of these issues and open-sourced our solutions in
sorbet-rails, a gem focusing on making the integration seamless.
The main challenge with type checking Rails code is that the framework relies a lot on meta-programming, i.e. creating methods at run-time. For example, it is extremely simple to define an ORM (Object-Relational-Model) class in Rails, because the framework automatically generates a lot of functionality for the model.
The use of meta-programming in Rails is very potent, as it helps engineers accomplish a lot with a few lines of code. However, because the methods are only added at run-time, it is a big challenge for a static type checker, which doesn’t run the code, to understand it. The static type checker doesn’t know about the existence of the methods, let alone knowing the methods’ signatures.
Sorbet provides a workaround for this problem: you can define method signatures for classes explicitly in Ruby Interface files (RBI). RBI files only contain method definitions, not executable code.
After attempting to write the RBI file for our first model, we realized how big of a task it would be to write the RBI for all our models. There must be a way to solve this with code! We started trying to generate RBI files automatically, and this concept became the backbone of the
GENERATING RAILS RBIs
sorbet-rails generates RBIs for Rails objects by inspecting the Rails environment at run-time. It does a few things:
- Detects generated methods (for each model or object),
- Figures out their signatures, and
- Writes the information to RBI files.
You can think of it as creating a “cached” snapshot of proper signatures of the methods created at run-time. It can become outdated if you change the code without regenerating it. However, Sorbet now has the signatures at its disposal to do type checking quickly.
Rails provides a lot of reflection methods, e.g.
AssociationReflection, that are invaluable for this process. However, there are generated methods for which Rails doesn’t provide enough information, like scopes or enum methods, and we have to find creative ways to collect the data needed. Currently,
sorbet-rails can generate RBIs for a few different kinds of Rails objects: Models, Routes, and Mailers.
Model RBI generation is the most fundamental but also most complex generation process in
sorbet-rails. We generate methods for:
- Model attributes: Based on database column definitions, we generate appropriate setters & getters for each attribute. Rails provides an easy accessor to retrieve this information.
- Model associations: Similarly, Rails has extensive reflections on associations. Using this information, we can generate getters and setters for the associations.
- Querying methods: These methods are implemented once in
ActiveRecord::Base, but in each subclass, they have a different return type. We generate them for each model to give them the correct return type.
- Scopes: Users can define “scope” to query a data collection in a model. Rails, however, doesn’t provide any reflection data for which scopes are defined in a model. We use the
source_locationof a method to determine whether they are scope methods. (Since they are generated methods, their “source_location” is the method that generates them).
- Enum: We overrode the enum method from Rails to collect
_suffixoptions that are not provided by Rails reflections.
Here is a snippet of the signatures
sorbet-railswould generate. See our sample RBI file and how it works in a type validation test.
It’s common for Rails gems or privately defined modules to create additional methods in a model dynamically.
sorbet-rails also provides a pluggable system that developers can hook into to generate signatures for them.
(Shout-out to Parlour gem for handling the complexity of generating RBIs!)
Each plugin can add any number of method signatures to the RBI of a model. The generator will reconcile all the added methods and skip over any conflicts. This design gives developers full control of the generation process (yes, they can even swap out a core plugin).
sorbet-rails comes with plugins for a few public gems, such as Shrine, ElasticSearch, and Kaminari. We welcome contributions for more gem plugins!
sorbet-rails generates signatures for the path and URL methods for all routes defined in a
routes.rb. They are very handy for type-checking code in your controllers. For example, assuming there is a route named
test_index, we would generate the following signatures.
Mailers pose an interesting challenge for generating good RBIs. In a Rails mailer, you define an instance method, and Rails will generate a class method with matching parameters. Other developers interact with the mailer using the class method, not the instance method. When you write the Sorbet signature for a mailer method,
sorbet-rails will create a signature for the class method based on the written signature — by retrieving the signature definition from Sorbet.
Generating RBIs simplifies the task of type checking Rails code. But it isn’t the end of the story. We also provide other features to make the task of adopting Sorbet seamless.
- Enabling the use of
::ActiveRecord_Relationin Sorbet signatures
What we haven’t mentioned is that
Relation classes like
Wizard::ActiveRecord_Relation aren’t available to use in Sorbet signatures. It’s a private constant generated by Rails, and if you use it, you’ll be greeted with “Accessing private constant” error. We added a function to automatically make the
Relation class of any model visible so that they can be used for type-checking!
2. Adding type to controller action params or external responses
The params of controller actions are input from users. They can have any type, and Sorbet normally cannot type check them. We added
TypedParams as a way to specify the structure and the type of each parameter. They have an added benefit: now we’ve documented the parameters needed for a controller action! This technique is also useful when dealing with response data from external services.
There are also many tips & tricks we documented that are useful for type-checking your Rails app.
sorbet-rails has enabled us to type-check a lot of our code without writing any method signatures manually. Just setting up
sorbet-railsgems, we were able to type check 80% of our files and 40% of the code in them. Our engineers reaped the benefit of improved productivity from type checking from day 1, as Sorbet has flagged numerous issues that manual review would have failed to catch.
As a fun exercise, I removed
sorbet-rails from our codebase and compared how much of a difference it makes. When added,
sorbet-rails reduced the number of untyped files by half and increased the type check scope of Sorbet significantly. It also doubled the number of call sites that Sorbet can type check.
The library has been received well by the community as well. We’ve seen adoption from many companies, such as Kickstarter and Heroku. The gem is also endorsed by the Sorbet team as the library to type-check Rails code.
There is also a vibrant community using
sorbet-rails: the project has been starred 300+ times, with 20+ public contributors who contributed ~70 commits (25% of the commits of the library!). I’d like to give special shout-outs for our awesome contributors, especially Connor Shea, Patrick Elis, and Alex Ghihecules. Without them, we wouldn’t have gone this far.
I’d like to thank the Sorbet team for the awesome tool that they created, and for listening to our feedback and collaborating with us to make the adoption successful!
If you’re interested in integrating Sorbet into your codebase, please look out for our next post on driving the adoption at the team level, what worked well and what didn’t.
While we’ve made Rails code a lot easier to type check, we’re not done yet. There are a number of technical challenges that we haven’t solved:
- Mailer: Methods with default values. Currently, we cannot generate a signature with a default value.
- ActiveJob: There are things we can do to make type-checking jobs easier, but we haven’t gotten there yet.
- Refresh RBIs automatically: The RBI files can be out of date. We would like to automatically detect changes and update RBI files automatically.
Sorbet is also a nascent project, and there are also features that Sorbet does not support that makes it hard to type check Rails code. Following are some key limitations we found:
- Method overloading: Sorbet doesn’t support multiple signatures for a single method, but it is a very common practice in Rails.
- Blocks: Type-checking blocks is challenging because a block can be executed in any context. It is difficult to type check configuration blocks like association extensions.
- Shape: Support for shape is also limited. While it’s feasible to use
T::Struct, it requires making a lot of code changes to type check code that uses a shape.
If you would like to solve these challenges, please feel free to contribute to Sorbet and the sorbet-rails project! We’re actively developing and managing this library.