Sorbet is a gradual type checker for Ruby, developed by Stripe. It was teased for about a year before release, and after getting enthralled by TypeScript back in early 2019, I eagerly awaited the public release of Sorbet as well. It came out in June 2019 and I adopted it in my Rails app vglist (source code) a few weeks later.

Ever since I started using it in vglist, I’ve been contributing to Sorbet-adjacent projects (like Sord, Parlour, and sorbet-rails) and advocating it to anyone who’d listen.

The main complaint I’ve heard about Sorbet - other than the syntax - is that Ruby ought not be typed, because it’d be restrictive. I agree! But Sorbet is gradual, meaning it can be adopted over time and parts of your code can remain untyped. You can do a first pass on a feature and then add the types once the code has solidified. This makes it easier for others to work with the code later. And you get these benefits without sacrificing the malleability of new Ruby code that makes rapid iteration so easy.

If you’re opposed to having type signatures entirely, there’s probably not much I can do to convince you. But I really do think types make large, complex applications much easier to follow and help teams ship their code with greater confidence.

What is Sorbet

An example of some Ruby code using Sorbet looks like this:

# typed: true
class Foo
  extend T::Sig

  sig { params(num: Integer).returns(Integer) }
  def self.double(num)
    num * 2
  end
end

Foo.double('bar') #=> Expected Integer but found String("bar") for argument num
T.reveal_type(Foo.double(10)) #=> Revealed type: Integer

If you’re familiar with Ruby, this code might look weird. The main addition that differentiates it from normal Ruby is the sig methods. sig blocks define the associated method’s parameters and return values. I’ve seen a lot of people say they find these ugly, but I actually quite like them. They’re just vanilla Ruby code and they work quite well with existing tooling (syntax highlighters, linters, etc).

Sorbet also has RBI files, which can be used to host type signatures separately from the source code. They’re useful for autogenerating signatures from gems you use and for typing metaprogramming-created methods.

For example, an RBI file for the code I showed above might look like this:

# typed: true
class Foo
  sig { params(num: Integer).returns(Integer) }
  def self.double(num); end
end

If you want to learn more about the specifics of Sorbet and how to write signatures with it, the Sorbet Docs are quite good. I recommend giving them a read.

If you want to try messing around with Sorbet, you can play with it right in your browser by using sorbet.run!

Benefits of adopting Sorbet

Editor Integration

Sorbet comes with a Language Server (powered by the Language Server Protocol from Microsoft) that makes it easy to integrate into text editors. Stripe has an official Sorbet VS Code extension it uses internally, but unfortunately it’s still in an experimental state and hasn’t been released publicly. It’s not particularly difficult to get your hands on though, and I’ve had it since around mid-2019. There are unofficial adapters for Vim, Sublime, and other editors, although I’ve not used any besides the official VS Code extension.

After using TypeScript in VS Code for a while, I really wanted proper IntelliSense support for Ruby. Solargraph is great, but it’s imperfect and misses a lot of methods. With Sorbet, you get diagnostics (aka editor decorations) for type errors, as well as Go To Definition, Go To References, and hover information like type signatures and documentation.

It makes it a lot easier to figure out where methods are coming from (“oh, this code uses Game.create_genre, is that a Rails-generated method or is it from one of our gems? Or maybe a method we added to the Game class?”), which improves the onboarding experience for new engineers and makes everyone’s lives easier, especially in a large, complex codebase.

Finding Bugs

One of the main selling points of Sorbet is that you can enable type checking in a file and have Sorbet discover existing problems for you (potential NoMethodErrors from calling methods on a nilable variable, for example). I’ve seen others report such successes, but I’ve admittedly never really run into this situation. Maybe my code is just built different, or it’s just less likely to unearth bugs when you’ve got a smaller codebase with ~90% test coverage.

The more compelling use-case for me here is that it will find bugs as you write them. Rather than needing to run your tests or open the application to figure out that you’ve typo’d a method name, Sorbet will yell at you inside your editor instead. Stripe describes this as being “like pair-programming with the type checker,” and I agree. It’s definitely one of the biggest selling points of Sorbet for me.

Acquiring and generating type signatures for Sorbet

There are a few main ways you can get type signatures for your Ruby code. Obviously, you can write them out yourself (and you’ll have to for most of your own custom code), but it’s unrealistic for any team to create and maintain type signatures for every gem they use. Auto-generated and community-maintained signatures are vital to using Sorbet effectively in a real application.

srb init

srb init is what Sorbet prompts you to run initially when installing the gem for the first time. It’s described more fully in the documentation, but essentially it runs all the code in your repository (and all the code in any gems you’ve installed) and will generate untyped method signatures based off that information. It generates an RBI file for every gem in your project as well as a hidden_definitions.rbi for anything where the origin can’t be determined by Sorbet (usually this is metaprogramming-created methods).

With this, Sorbet will know about almost every method you can possibly call in your application, which helps it detect issues (like methods that don’t exist on a given class).

After the initial setup, srb rbi update can be used instead of srb init to regenerate type signatures.

sorbet-typed

In TypeScript, there’s a project called DefinitelyTyped. DefinitelyTyped is a community repository of type signatures for a huge variety of popular libraries, and if you’ve used TypeScript you’ve likely used DefinitelyTyped type signatures.

sorbet-typed is essentially Sorbet’s equivalent of DefinitelyTyped. Community members contribute type signatures for popular libraries (such as ActiveSupport and Faker), and Sorbet will pull in the relevant signatures when you run srb init or srb rbi sorbet-typed. It provides basic coverage for a lot of popular Ruby libraries and is very useful for setting up some initial type coverage in your application.

sorbet-rails

sorbet-rails is a gem made by the community that provides Rake tasks for generating RBI files for all the dynamic code in Rails apps. It will generate type signatures for all the models in your app, with typed methods for all their attributes and relations. It also generates type signatures for route methods (e.g. user_path), jobs, mailers, and helpers. It’s also extensible with a fairly straightforward plugin system with built-in plugins for a handful of popular gems that integrate with Rails (e.g. pg_search, kaminari, and friendly_id), as well as the ability to create your own plugins.

How I use Sorbet in vglist

I originally used just Sorbet’s srb init/srb rbi update, sorbet-typed, and sorbet-rails. This was okay. It worked pretty well, but every time a gem was updated, every time a new model was introduced, or every time a model got a new attribute, I had to go through a tedious process of regenerating my type signatures.

Since I was using srb init/srb rbi update, it was generating type signatures for a massive amount of code. As mentioned before, it does this by running every single line of code in the entire codebase. You then get an RBI file for every gem in your project, as well as a “hidden definitions” file. hidden_definitions.rbi is a massive file generated by Sorbet with any methods that it can’t figure out the source of (usually, methods created via metaprogramming magic, like Rails’ dirty tracking foobar_changed?-type methods) and anything from the core Ruby language that Sorbet’s standard library types didn’t yet know about (common for newer versions of Ruby). In my project, it varied in size over time, but it was pretty much always between 25k and 40k lines long. This is a huge file, and “maintaining” it added a ton of overhead to pull requests.

It took 5-10 minutes to regenerate all the RBI files that Sorbet needed, and - because it ran into some code for generating my seeds that used database_cleaner - it wiped out my local development database in the process (I had seeds that were pretty fast, so that’s not an unacceptable side effect, but still annoying). Not only that, it was also extremely sensitive to specific problematic gems that had errors (like gems with optional dependencies or infinite loops), and there was no way to have it ignore a specific gem’s code. This was incredibly frustrating to work with, and I’ve heard from others with larger projects that it takes as long as an hour for them to run srb rbi update. This wasn’t feasible long-term, and I kept looking for better alternatives to solve this problem.

Honestly, with this initial process I couldn’t really recommend Sorbet for big Rails apps in good faith. It was mostly usable, but the massive burden of regenerating type signatures like that made it infeasible for larger apps to adopt.

Introducing: Tapioca

Tapioca is a gem from the fine folks over at Shopify. It aims to replace a lot of the clunky parts of Sorbet’s built-in RBI file generation. There are some tradeoffs, but I think it’s ultimately superior in nearly every way.

The main difference is that it does not run the code in your codebase to generate the type signatures. This has drawbacks, namely that it means metaprogramming-created methods get missed. It also means that you’ll miss out on any methods in core Ruby that aren’t defined by Sorbet’s stdlib type definitions (Sorbet’s stdlib type coverage is very good, so this is only typically a problem when using new methods in brand new Ruby versions). However, it has a number of benefits as well. For one, you don’t need to worry about problems like getting stuck in infinite loops or databases getting wiped. It also makes regenerating your type signatures much faster, because each gem is evaluated independently, and if the gem hasn’t changed versions, Tapioca will simply skip trying to regenerate it.

This takes the autogeneration time down from 5-10 minutes for my app to taking about one second per gem thats been updated. From what I understand, this scales quite well for larger apps that would otherwise take 30-90 minutes to autogenerate types.

Tapioca also offers support for auto-generating signatures for various Rails DSLs, although I don’t currently use it as sorbet-rails has worked fine for me thus far.

For a more detailed comparison of Sorbet’s built-in autogeneration and what Tapioca does, see Tapioca’s GitHub Wiki page on the topic.

For instructions on setting it up for your Rails app, see the Usage section of the README.

rake sorbet:update:all

This is the Rake task I use to regenerate all my Sorbet signatures, which I do when I’ve changed any of the models (or just every few weeks, if I’m not actively working on the app for a little while). It makes maintenance of the application’s type signatures very simple, and only takes about 45 seconds to run on my MacBook Pro.

# sorbet.rake
namespace :sorbet do
  namespace :update do
    desc "Update Sorbet and Sorbet Rails RBIs."
    task all: :environment do
      Bundler.with_unbundled_env do
        # Pull in community-created RBIs for popular gems, such as Faker.
        #
        # If you want to use a fork of sorbet-typed for any reason, you can set
        # SRB_SORBET_TYPED_REPO to the git URL and SRB_SORBET_TYPED_REVISION
        # to the "origin/master"-type branch reference).
        system('bundle exec srb rbi sorbet-typed')
        # We don't want to include the RBI files for these gems since they're not useful.
        puts 'Removing unwanted gem definitions from sorbet-typed...'
        ['rspec-core', 'rake', 'rubocop'].each do |gem|
          FileUtils.remove_dir(Rails.root.join("sorbet/rbi/sorbet-typed/lib/#{gem}"))
        end
        # Use Tapioca to generate RBIs for gems
        system('bundle exec tapioca sync')
        # Generate Sorbet Rails RBIs.
        system('bundle exec rake rails_rbi:all')
        # Generate a TODO RBI for constants Tapioca doesn't understand.
        system('bundle exec tapioca todo')
        # Run suggest-typed to increase/decrease the type level of files
        # as-necessary (for example, if types became more strict in an
        # autogenerated RBI this may cause Sorbet to downgrade the `typed:`
        # sigil for one of your files to `false`). Ensures that our code will
        # pass type checking regardless of any changes to the autogenerated
        # RBIs.
        system('bundle exec srb rbi suggest-typed')
      end
    end
  end
end

With this, it’s much easier to keep my type signatures in sync with any changes to my Rails app or gem dependencies.

Caveats and Considerations

Sorbet is imperfect, and can’t be used to type everything. It’s unable to understand many DSLs, such as FactoryBot factories, RSpec tests, and Rake tasks. Custom support for those DSLs is possible in the future, but as-of-now code that heavily uses DSLs may present a problem for new adopters of Sorbet.

The other notable problem I’ve had with Sorbet has using it with newer Ruby versions. Sorbet is typically a few months behind on supporting the latest Ruby version, so using newly-introduced syntax will cause its parser to fail.

The goal should be to try to add signatures on common methods to improve the typedness of the codebase quickly at the start. With lower-usage methods getting updated later on.

If you’re finding it difficult to type a method, either the method is too complex and should be refactored, or you’ve hit something that Sorbet isn’t currently capable of handling. Rather than try to brute force a signature that restricts the ways you can use the method or a signature that’s extremely complex, it’s a better idea to just accept that the method will remain untyped for now and move on.

Sorbet is really great, and I’m happy to have adopted it. It’s made writing Ruby more enjoyable (and it already was before!), and I hope it gains further adoption in the future. I wanted to write this blog post because I saw a lot of people struggling with Sorbet’s defaults, and after refining my setup for so long I thought it would be worth writing out for others to learn from. Hopefully you’ll give Sorbet a shot in your Rails app. Thanks to Stripe and the Sorbet team for creating and releasing Sorbet, to CZI for sorbet-rails, to Aaron Christiansen for Parlour and Sord, to Shopify for Tapioca, and to the rest of the Sorbet community for everything you’ve contributed to making it as great as it is!