Static Typing in Ruby with a Side of Sorbet

Listen to this article

As an experiment to see how static typing could help improve our team’s Ruby experience, we introduced Sorbet into a greenfield codebase with a team of 4 developers. Our theory was that adding static type checking through Sorbet could help us catch bugs before they go into production, make refactoring easier, and improve the design of our code. The short answer is that yes, it did all of that! Read on to learn a little more about what it was like to build in a type safe Ruby.

The Sorbet project's logo

Ruby is a dynamic language, which means that types are checked when the code is run. If you try to call a method on an object that does not exist, the compiler will not complain, you'll only find out about that error when the code is executed and you get a NoMethodError .

Static languages avoid this problem. In a static language, such as Java, the compiler is told or can interpret the type of each variable, and return values from a method or function. This means, among other things, that if you try to call a method that does not exist on an object you will get an error at compile time.

# Ruby code user = "Jessica" user.send_email # Fails with NoMethodError when the code is executed

// Java code String user = "Jessica"; user.sendEmail(); // Fails at compile time

Statically typed languages usually have more overhead in that you need to declare the types of your objects. The added advantage is that they can possibly prevent errors in your code before they're exposed to a user.

Which is where Sorbet comes in. Originally developed by the Stripe team, it lets you use Ruby like you normally would, but also gain the advantages of static type checking. Sorbet accomplishes this in two ways, the first is by using static linting of the files. While that can help you enforce types to a certain extent, the more effective way of type checking is to have the linting happen at runtime. Sorbet will inject itself into each method call and verify that the parameters and return value match what is defined in your signature. This checking adds practically no additional processing time, so there’s no performance issues to worry about.

In the Ruby example above, instead of getting a NoMethodError , with Sorbet, you’d get Method send_email does not exist on T.class_of(String) . That’s a much more useful error message for a developer.

Sorbet is designed to be added to existing codebases so the development team has spent a lot of time perfecting that experience. The process of getting the project set up on basic type checking was very easy, you only need to run sorbet init to commit the initial configuration files. After setting up the configuration files, you can use sorbet tc to run type checking. Setting up our CI server only required adding an additional call to sorbet tc .

For code that is not part of your application, Sorbet provides the option to use RBI files, which are similar to header files in other languages. They are files that allow you to add the method signatures of code that you do not control. This feature is useful to make sure that when you’re using code from a gem, you can still get the benefits of static typing.

Instead of making you do the work of defining a new RBI file for each gem you use, Sorbet also has a GitHub repo where users can add RBI files for gems and version them, this is similar to the TypeScript DefinitleyTyped repo. They provide a command line tool that will scan your gems and pull down any RBI files that are available. These files are then committed to your repo. Gem authors can also provide an RBI file in the gem source, as Sorbet supports that directly, but most authors have not added signatures.

The meta-programming features of Ruby often come in direct conflict with the static typing of Sorbet. One of these issues is Rails and ActiveRecord. When using an ActiveRecord model, you will have methods defined on the class, based on columns in the database. If you would like to be able to use static typing when interacting with these models, you must create RBI files for your models and add the signatures for the methods as you use them.

# typed: strong class User < ActiveRecord::Base extend T::Sig extend T::Generic sig { params(id: String).returns(T.nilable(User)) } def self.find_by_id(id); end end sorbet/rbi/app/models/user.rbi , a Ruby Interface, or RBI, file

There is a gem, sorbet-rails, that will attempt to analyze your Rails app and generate the RBI files automatically. As with Sorbet, the project is still young. It was helpful in jump starting some of our typing, but it still misses or mistypes certain fields.

Sadly, very little editor integration is supported at the moment. The Sorbet team is still keeping their plugins closed source until they’re ready for release. For editors that support a way to easily add custom checks, such as Ale in Vim, the CLI works great and is fast enough to give quick feedback. I’m sure that the development experience will improve significantly once the editor integration is available.

We had a strong belief that Sorbet could improve our development experience by lowering our errors and showing us possible problems earlier in our local development. We were very pleased to find that as soon as we added Sorbet and types to our project, Sorbet immediately pointed out a problem in our code! As we continued to add types, Sorbet exposed more issues. I've noted a few of the more interesting problems Sorbet helped us to resolve.

Sorbet complained that we were not properly checking the return type of a method that we called. We did not check that because the same method was called previously in the method and the assumption would be that the underlying value would not change. While that is probably true in almost every case, given our knowledge of that class, we can avoid the problem by assigning the variable out. In this case, Sorbet made our code more reliable.

We decided that we would try our best to not allow nils into our type signatures unless it was unavoidable. This meant that we would rewrite our code where necessary to accommodate this decision. Early into this process, Sorbet made it apparent that we had some columns in our database that we had allowed to be null when that was not what we wanted. This prompted a migration on our database to prevent future problems.

Using Sorbet to define the params and return type can sometimes provide a small benefit by removing tests. In particular there were some tests in our app that were ensuring that the methods returned nil or true , as a dummy value so that callers would not rely on the value. Using type checking allows us to remove those explicit returns. In the case of a method that we do not want to use the return value, Sorbet, provides a void return type that will return a dummy value. Sorbet can also remove guard clauses such as raise Error if arg.nil? by using params checking. This can lower the complexity of the code and the number of tests that are written.

Time will tell if Sorbet is a good long term addition, but in the short term, our experience has been very positive. Getting the initial project up and running was very quick and most of the bugs we hit were related to the young age of the Sorbet project. The project maintainers have built a system that stays out of the way until you want it to help and when it does, it provides a solid experience.

Check out the in-browser Sorbet Playground to try Sorbet out without having to download and set up the gem in your codebase.