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.
Top comments (1)
This is really great, thank you for sharing. Looking forward to the next post in the series!