DEV Community

Cover image for Getting Started with the Sorbet Type Checker in Rails
Akshay Nathan
Akshay Nathan

Posted on • Edited on

Getting Started with the Sorbet Type Checker in Rails

At Monolist, we’re building a command center for engineers. We integrate with the APIs of all the tools engineers commonly use to aggregate their tasks in one place. For example, our customers trust us to pull in their Jira issues, Github pull requests, and Pagerduty alerts in real-time, so that they can triage, prioritize, and complete their work all from within Monolist.

Unfortunately, this is easier said than done. The APIs of these SaaS tools vary wildly, and assumptions about responses and return values propagate through our Ruby on Rails backend. When these assumptions are incorrect, because of Ruby’s dynamic nature, they manifest as runtime errors that cause issues for our users, and are difficult to debug. We realized quickly that we could benefit from types. While our frontend and mobile clients are written in Typescript, we didn’t have a good solution for our large rails API.

Enter Sorbet. Sorbet is a ruby typechecker written by Stripe. Like Typescript, Sorbet allows for gradual typing, which enables us to slowly introduce types to our codebase. In other words, we can reap the safety benefits of adding types file by file.

In this blog series, we’ll detail our experiences with adding Sorbet to a large-ish (> 100k LoC) Rails codebase. In this first post, we’ll add sorbet to the project, and add types to our first file.

Setup

We’ll start by adding Sorbet and the Sorbet runtime to our Gemfile. Sorbet provides both static analysis, and runtime checks. While the runtime checks will run in production, we’ll only need the static analysis in development, so we can separate the gems by environment.

gem "sorbet", group: :development
gem "sorbet-runtime"

According to the Sorbet documentation, at this point we can run:

bundle exec srb init

Unfortunately, here’s where we hit our first hurdle – the command never completed. The "srb init" command requires every .rb file in the repository, and uses runtime reflection to detect missing constants and generate RBI type definition files for your existing code. If you have a large number of vendored dependencies in vendor/bundle/ or node_modules/, this can be an extremely long (and sometimes infinite) process.

There are a couple open Github issues about this problem. Luckily, the help output from srb init gives us an easy way to solve this problem. We can simply add # typed: ignore to all the ruby files in our vendor/ and node_modules/ directories, and Sorbet will skip them.

for rb in $(find vendor node_modules -type f -name '*.rb');
  do sed -i '1i\# typed: ignore' "$rb"
done

Now when we run srb init, the command completes. We can now run:

➜  bundle exec srb tc
No errors! Great job.

Great!

Turning on the type-checker

The good news is we have no errors. The bad news is that we’re not actually type-checking most of our code.
This is because when srb init cannot typecheck an entire file due to missing method type signatures,
it will add # typed: false or # typed: ignore to the file.
This means that srb tc will not typecheck the types and methods defined in these files or their callers.

Let’s take a look at one of our files:

# typed: false
class CreateStripeBillingCustomer
  def call(user:, stripe_token: nil)
    return if user.email.match?(/test\+\d*@monolist.co/)

    stripe_customer = Stripe::Customer.create({ email: user.email, source: stripe_token })

    BillingCustomer.create!({ user: user, stripeid: stripe_customer.id  })
  end
end

This service is responsible for initializing a user with Stripe, our billing platform.
For a non-test user, it calls the Stripe API to initialize a customer, and stores the customer_id in our database.

Billing code is especially sensitive – you don’t want to double charge a customer because of an unexpected null object.
We figured that our billing code would be a good place to start adding Sorbet types, and this service is an especially good candidate because of its simplicity.

Let’s change # typed: false to # typed: true.
In # typed: true mode, Sorbet only checks types for methods that it knows about. That is, Sorbet
only checks methods that are explicitly annotated, either via hand-written type signatures or type definition RBI files.
For any other methods, Sorbet assumes their return values are T.untyped, which is a special type that matches all types.
While # typed: true isn’t the strictest static type checking level, we can start with it to enable us to gradually type our codebase.

Once we’ve changed the annotation, we can rerun srb tc.

➜  bundle exec srb tc
app/services/billing/create_stripe_billing_customer.rb:6: Method create does not exist on T.class_of(Stripe::Customer) https://srb.help/7003
     6 |    stripe_customer = Stripe::Customer.create({
     7 |      name: user.display_name,
     8 |      email: user.email,
     9 |      source: stripe_token,
    10 |    })
  Autocorrect: Use `-a` to autocorrect
    app/services/billing/create_stripe_billing_customer.rb:6: Replace with freeze
     6 |    stripe_customer = Stripe::Customer.create({
                                               ^^^^^^

Errors: 1

Hm, it appears that Sorbet doesn’t seem to know about the Stripe::Customer.create method.
In Sorbet, all methods must either include an inline type signature via a “sig”, which we’ll see later, or must include type signatures in a corresponding “.rbi” file.

Adding type signatures

Sorbet ships with .rbi files for the Ruby standard library. All other type definition files must either be hand-written, or pulled down from sorbet/sorbet-typed, a central repository of types for common gems.

When we run srb init, sorbet will automatically pull these type definitions for any gems installed in our gem file. Let’s see what it did:

ls sorbet/rbi/sorbet-typed/lib
actionmailer  activemodel   activesupport railties      sidekiq
actionpack    activerecord  bundler       rainbow       stripe
actionview    activestorage minitest      ruby

At first, it looks like we pulled down all the definitions for the “stripe” gem. However, upon closer inspection:

cat sorbet/rbi/sorbet-typed/lib/stripe/all/stripe.rbi
# This file is autogenerated. Do not edit it by hand. Regenerate it with:
#   srb rbi sorbet-typed
#
# If you would like to make changes to this file, great! Please upstream any changes you make here:
#
#   https://github.com/sorbet/sorbet-typed/edit/master/lib/stripe/all/stripe.rbi
#
# typed: strong

class Stripe::Card
  sig { returns(String) }
  def brand; end

  sig { params(other: String).returns(String) }
  def brand=(other); end

  sig { returns(Integer) }
  def exp_month; end

  sig { params(other: Integer).returns(Integer) }
  def exp_month=(other); end

  sig { returns(Integer) }
  def exp_year; end

  sig { params(other: Integer).returns(Integer) }
  def exp_year=(other); end

  sig { returns(String) }
  def last4; end

  sig { params(other: String).returns(String) }
  def last4=(other); end
end

It looks like the “sorbet-typed” definitions for the stripe gem are really sparse. This was new to us after learning to lean so heavily on DefinitelyTyped, which is the Typescript equivalent of a shared type library.

However, this is easily solvable. Let’s add a .rbi file of our own at "sorbet/rbi/lib/stripe/customer.rb".

class Stripe::Customer
  sig do
    params({
      email: T.nilable(String),
      source: T.nilable(String),
    }).returns(Stripe::Customer)
  end
  def self.create(email:, source:); end
end

Here we define the create method in accordance to the Stripe API documentation.
Our sig states that the method takes in an optional String email, and an optional String source, and returns an Stripe::Customer.
Let’s run srb tc again.

➜  bundle exec srb tc
app/services/billing/create_stripe_billing_customer.rb:8: Method id does not exist on Stripe::Customer https://srb.help/7003
     8 |    BillingCustomer.create!({ user: user, stripeid: stripe_customer.id })
                                                            ^^^^^^^^^^^^^^^^^^
Errors: 1

Let’s add the id method to our Stripe::Customer type.

sig do
  returns(String)
end
def id; end

And now…

➜  bundle exec srb tc
No errors! Great job.

Stronger Guarantees

Remember when we said that # typed: true isn’t the strictest type-checking mode? Now that we’ve resolved the errors with that level of strictness, let’s see if we can go further with the aptly named # typed: strict.

Once we changed the annotation, we can rerun srb tc.

➜  bundle exec srb tc
app/services/billing/create_stripe_billing_customer.rb:3: This function does not have a `sig` https://srb.help/7017
     3 |  def call(user:, stripe_token: nil)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    app/services/billing/create_stripe_billing_customer.rb:3: Insert sig {params(user: T.untyped, stripe_token: T.untyped).returns(T.untyped)}

     3 |  def call(user:, stripe_token: nil)
          ^
  Autocorrect: Use `-a` to autocorrect
    app/services/billing/create_stripe_billing_customer.rb:3: Insert   extend T::Sig

     3 |  def call(user:, stripe_token: nil)

In strict mode, Sorbet mandates that all methods must have a signature. Let’s add one:

sig do
  params({
    user: User,
    stripe_token: T.nilable(String)
  }).returns(T.nilable(BillingCustomer))
end
def call(user:, stripe_token: nil)

Now when we rerun srb tc:

➜  bundle exec srb tc
app/services/billing/create_stripe_billing_customer.rb:12: Method email does not exist on User https://srb.help/7003
    12 |    return if user.email.match?(/demo\+\d*@monolist.co/)
                      ^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    app/services/billing/create_stripe_billing_customer.rb:12: Replace with eval
    12 |    return if user.email.match?(/demo\+\d*@monolist.co/)
                           ^^^^^

app/services/billing/create_stripe_billing_customer.rb:14: Method email does not exist on User https://srb.help/7003
    14 |    stripe_customer = Stripe::Customer.create({ email: user.email, source: stripe_token })
                                                               ^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    app/services/billing/create_stripe_billing_customer.rb:14: Replace with eval
    14 |    stripe_customer = Stripe::Customer.create({ email: user.email, source: stripe_token })
                                                                    ^^^^^

Errors: 2

The user.email method is dynamically generated by ActiveRecord from the database schema. Unfortunately, Sorbet does not know about dynamically generated methods, and adding .rbi definitions for every column among our 100+ models would be incredibly tedious.
Luckily, the open-source project "sorbet-rails" allows us to autogenerate .rbi files for our models.

Once we install it with gem "sorbet-rails", we can run bundle exec rake rails_rbi:models to generate .rbi files for every model in our project. Let’s rerun srb tc:

➜  bundle exec srb tc
app/services/billing/create_stripe_billing_customer.rb:12: Method match? does not exist on NilClass component of T.nilable(String) https://srb.help/7003
    12 |    return if user.email.match?(/demo\+\d*@monolist.co/)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    app/services/billing/create_stripe_billing_customer.rb:12: Replace with T.must(user.email)
    12 |    return if user.email.match?(/demo\+\d*@monolist.co/)
                      ^^^^^^^^^^
Errors: 1

Whoa! Our first real type error! Let’s look into the generated signature in "sorbet/rails-rbi/models/user.rbi"

sig { returns(T.nilable(String)) }
def email; end

Looks like sorbet-rails assigned T.nilable to the return type of User#email. This is because the email column is nullable in our database. This is a legacy remnant from when email address was not required to sign up for Monolist. For these legacy users, we should simply ignore them and not attempt to create a customer on Stripe.

Let’s implement this:

def call(user:, stripe_token: nil)
  return unless (email = user.email)
  return if email.match?(/demo\+\d*@monolist.co/)

  stripe_customer = Stripe::Customer.create({ email: email, source: stripe_token })

  BillingCustomer.create!({ user: user, stripeid: stripe_customer.id })
end

Now, when we rerun srb tc:

➜  bundle exec srb tc
No errors! Great job.

Here, we can see the value of Sorbet’s “flow-sensitivity”. This means that Sorbet tracks types through program control flow. Although user.email starts as T.nilable(String), the conditional check on line 1 unwraps email to String, fixing the type error from before.

Conclusion

In this blog post, we setup our Rails API project with Sorbet, added type definitions for our models and some of our external dependencies, and fully typed our first file. While Sorbet still has some rough edges (inflexible initialization process, small type library), we were still able to relatively easily enable it’s static type-checking and discover our first real type error.

At Monolist, we believe that adding types is a great way to ensure the stability of the API integrations that our customers depend on. In the next posts in this series, we’ll integrate Sorbet into our CI system, enable runtime checking, and start adding types to more complicated code paths.


Want to see how Monolist can enable engineers like you to be more productive? Sign up for free here.

Liked this post? Join our mailing list for more content here.

Top comments (1)

Collapse
 
peter profile image
Peter Kim Frank

This is really great, thank you for sharing. Looking forward to the next post in the series!