RUBY ON RAILS · 17 MIN READ ·

Rails Sorbet: Adding Gradual Type Safety to Legacy Rails Applications with sig, tapioca and CI

Rails Sorbet guide: add gradual type safety to legacy Rails apps with sig, tapioca RBI generation, srb tc, strictness levels, and CI integration.

Rails Sorbet: Adding Gradual Type Safety to Legacy Rails Applications with sig, tapioca and CI

A SaaS client dropped a nine-year-old Rails 6.1 codebase on my desk in April and asked me to figure out why a two-line refactor kept breaking payroll calculations for a subset of their enterprise customers. The offending method was called calculate_gross(period, employee, opts = {}) and the opts hash was passed through eleven layers of business logic before landing in a tax module. Nobody on the team could tell me what keys opts accepted. Nobody could tell me what period was — a Date, a DateRange, a String, a symbol like :month? All four were passed in from different call sites. The bug was that one caller was passing Date.today.beginning_of_month..Date.today.end_of_month and the tax module was calling .beginning_of_month on the range object. Rails Sorbet would have flagged this in seconds. Instead it took me two afternoons and a git blame session that revealed the original author had left the company in 2019.

After nineteen years of Rails I finally stopped treating type annotations as a Java affectation and started shipping them into every long-lived Rails app I touch. Rails Sorbet is the pragmatic choice — it is what Stripe built for their own million-line Ruby monolith, it is what Shopify and Chime and Coinbase run in CI, and unlike RBS (the official Ruby type signature format) it has a working ecosystem, a mature editor experience, and tooling that generates most of the annotations for you. This post is how I introduce Rails Sorbet into a legacy Rails codebase without triggering a rebellion, what to type first, and how to keep it running in CI without turning every PR into a type-annotation slog.

Why Rails Sorbet and Not RBS

The Ruby community has two type systems. RBS is the official one, blessed by matz, and lives in .rbs files alongside your .rb files. Sorbet is the Stripe-built one, uses inline sig { ... } annotations, and predates RBS by three years. Both check types statically. Neither changes runtime behavior unless you opt into the runtime checker.

I run Rails Sorbet for four reasons that have not changed in the last three years:

Sorbet has Tapioca. Tapioca (from Shopify) automatically generates type stubs — called RBI files — for your gems, your ActiveRecord models, your Rails routes, your DSLs, and the whole meta-programming layer of Rails. Without Tapioca you would hand-write signatures for every ActiveRecord attribute, every association, every helper method. With Tapioca you run bin/tapioca gem and get 40,000 lines of generated RBI in a directory you never look at.

Sorbet’s editor integration works. The Sorbet language server plugs into VS Code, RubyMine, Neovim, and Zed. Jump-to-definition works across gems. Autocomplete knows the return type of User.find. Hover shows the signature of Money#format. RBS’s language server exists but is years behind in polish and coverage.

Sorbet has strictness levels. Every file has a # typed: sigil at the top — false, true, strict, or strong — and you migrate files one at a time from false to true to strict. This is the whole game with gradual typing. RBS treats everything as untyped by default with no per-file dial.

Sorbet is battle-tested at scale. Stripe runs it on 20 million lines of Ruby. Whatever weird Rails pattern you have, someone at Stripe has already fought Sorbet about it and either fixed it or documented the workaround.

RBS is the future in the sense that it might eventually become the community standard, but Sorbet is the present in the sense that you can adopt it on Monday and ship real type checking to CI by Friday. I choose the tool that works today.

Installing Rails Sorbet in an Existing Application

Add three gems and initialize:

# Gemfile
group :development do
  gem "sorbet", require: false
  gem "tapioca", require: false
end

group :development, :test do
  gem "sorbet-runtime"
end

sorbet is the type checker binary. sorbet-runtime provides the sig DSL and the optional runtime checks. tapioca generates RBI files for your gems and Rails DSLs. Then:

bundle install
bin/tapioca init

tapioca init creates sorbet/config, a sorbet/rbi/ directory tree, and adds a # typed: false sigil to every existing .rb file in your app. Every file starts untyped, which is the entire point — nothing you already have breaks, and you opt into checking file by file.

Then generate RBI for your gems:

bin/tapioca gem

This takes a few minutes on a large Gemfile because Tapioca boots each gem in isolation to extract its API surface. You will end up with sorbet/rbi/gems/rails@8.0.0.rbi and similar files. Commit them — they are part of your source of truth about what your gems’ APIs look like.

For Rails-specific meta-programming (ActiveRecord attributes, routes, associations, mailers, jobs), run:

bin/tapioca dsl

This inspects your models and generates RBI files that declare, for example, that User#email returns String and User#posts returns ActiveRecord::Relation[Post]. Re-run bin/tapioca dsl whenever you add a migration or change a model. In practice you wire this into a git hook or a bin script.

Now run the checker:

bin/srb tc

You will see zero errors. Every file is # typed: false, so Sorbet is only checking that your RBI files are internally consistent.

Writing Your First sig

Pick one file. Not a model — models have too many Rails-generated methods. Pick a plain old Ruby object in app/services/ or app/lib/. Something with a small public surface, few callers, and no metaprogramming. That is your first candidate.

Change the sigil at the top of the file from # typed: false to # typed: true and add signatures:

# typed: true
# frozen_string_literal: true

class PayrollCalculator
  extend T::Sig

  sig { params(period: Date, employee: Employee, opts: Options).returns(Money) }
  def calculate_gross(period, employee, opts)
    base = employee.hourly_rate * hours_worked(period, employee)
    bonus = opts.include_bonus? ? bonus_for(period, employee) : Money.zero
    base + bonus - deductions(period, employee)
  end

  private

  sig { params(period: Date, employee: Employee).returns(Integer) }
  def hours_worked(period, employee)
    Timesheet.for(employee, period).sum(:hours)
  end
end

class Options < T::Struct
  const :include_bonus, T::Boolean, default: true
  const :round_to_cents, T::Boolean, default: true
end

Three things happen the moment you save this file.

First, bin/srb tc will complain that Options is not defined, that Money is not defined, that Employee#hourly_rate returns T.untyped. This is fine — it is telling you the truth about what it cannot verify yet. You either add types to those files too (opt in gradually), or you tell Sorbet to trust you on specific values with T.let and T.cast.

Second, your editor’s Sorbet extension will start showing red squiggles under any caller of calculate_gross that passes a string where a Date is expected. This is where the value shows up: you find bugs in code that has been broken for years by turning on the checker for one method.

Third, if you also require sorbet-runtime at boot, every call to calculate_gross will raise TypeError at runtime if the arguments do not match the sig. This is off by default at request time — the runtime checker is opt-in per file — but I turn it on for development and test environments so my test suite catches type violations that the static checker missed because of T.untyped propagation.

Tapioca: The Reason Rails Sorbet Is Bearable

Rails is heavy on meta-programming. has_many :posts generates #posts, #posts=, #post_ids, #post_ids=, #build_post, and about a dozen more methods dynamically. attr_accessor :name generates #name and #name=. Sorbet cannot see any of this without help. Hand-writing signatures for every generated method is a non-starter.

Tapioca’s dsl compilers introspect your Rails app at boot time and write RBI files that describe what the meta-programming produced. For a model like:

class User < ApplicationRecord
  has_many :posts
  belongs_to :organization
  enum role: { admin: 0, member: 1 }
  validates :email, presence: true
end

Tapioca generates something like:

# sorbet/rbi/dsl/user.rbi (generated — do not edit)
class User
  sig { returns(ActiveRecord::Associations::CollectionProxy[Post]) }
  def posts; end

  sig { returns(Organization) }
  def organization; end

  sig { returns(String) }
  def email; end

  sig { params(value: String).returns(String) }
  def email=(value); end

  sig { returns(T::Boolean) }
  def admin?; end
end

Now User.find(1).posts.where(published: true) type-checks. User.find(1).emial (typo) is a static error. You did not write a single annotation on the model itself.

Re-run bin/tapioca dsl after any migration or model change. On my client projects this is wired into bin/setup, bin/rails db:migrate, and a pre-push git hook so nobody forgets. There is also bin/tapioca todo which generates RBI files describing methods that Sorbet cannot resolve but that exist at runtime — a temporary crutch for the migration.

Strictness Levels: The Migration Dial

Every .rb file has a sigil that controls how strictly Sorbet checks it:

  • # typed: false — Sorbet ignores the file entirely except for syntax errors. This is the default and the safe starting point.
  • # typed: true — Sorbet checks everything it can, but T.untyped values propagate silently. This catches typos, missing methods, and obvious argument mismatches.
  • # typed: strict — Every method must have a sig. Every constant must have an explicit type. T.untyped is a warning. This is where you catch the interesting bugs.
  • # typed: strong — No T.untyped allowed anywhere. Almost nothing in a real Rails app compiles at this level; use it for pure Ruby libraries.

The migration path for a legacy Rails app is: leave everything at false, bump plain-old-Ruby files in app/services/, app/lib/, app/models/concerns/ to true first, then work outward. Models can usually go to true once you have Tapioca DSL RBIs. Controllers are hardest because of params (which Sorbet knows is a ActionController::Parameters but not the shape of its contents) — I leave controllers at false for the first year and rely on strong params + request specs.

The rule I give teams: you may not lower a file’s strictness in a PR. Only raise it. This ratchets coverage upward over time without a big migration project.

Running srb tc in CI

The whole point of static typing is catching errors before merge. Add a GitHub Actions job:

# .github/workflows/ci.yml
name: CI
on: [pull_request]

jobs:
  sorbet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bin/tapioca dsl --verify
      - run: bin/srb tc

bin/tapioca dsl --verify fails the build if the committed DSL RBI files are out of date with the current models — this prevents “I forgot to regenerate after adding a column” bugs. bin/srb tc runs the type checker and fails on any error.

Sorbet is fast because it is written in C++ and processes files in parallel. On my 400,000-line client project, a cold bin/srb tc takes 8 seconds; on their Shopify-scale friends it takes about 30 seconds. This is fast enough to run on every push without slowing down the pipeline.

What Rails Sorbet Will Not Catch

Type systems catch a specific class of bug: wrong shape, wrong type, missing method, misspelled method. They do not catch:

  • Business logic errors. If calculate_gross returns the right type but the wrong number, Sorbet is happy. You still need tests.
  • N+1 queries. Sorbet knows user.posts returns a relation; it does not know you are calling it inside a loop.
  • SQL correctness. Sorbet does not read your ActiveRecord queries.
  • Race conditions and concurrency bugs. Types do not describe temporal behavior.
  • Metaprogramming that Tapioca cannot see. define_method inside a block based on runtime data will confuse both the DSL compilers and the static checker. You will need hand-written RBIs for the trickiest DSLs.

I say this because teams occasionally believe adding Rails Sorbet means they can ship with less test coverage. It does not. It means their test suite stops needing to test type shapes and can focus on business logic.

When Not to Use Rails Sorbet

A greenfield Rails 8 side project of 5,000 lines with one developer does not need Sorbet. The overhead of the tooling — regenerating RBIs, editor setup, teaching the syntax — is real, and small codebases with one context-holder do not benefit enough. Add Sorbet when the team hits three developers, when the codebase crosses 30,000 lines, or when you inherit a legacy app with no documentation.

Also do not use Sorbet if your team is unwilling to run bin/tapioca dsl after model changes. Stale RBIs are worse than no types because they lie. If the ratchet — never lower strictness, always regenerate — is not enforced socially or in CI, the whole thing rots within a quarter.

FAQ

What is Rails Sorbet and how does it differ from RBS?

Rails Sorbet is Stripe’s static type checker for Ruby, applied to Rails applications. It uses inline sig { ... } annotations in your .rb files, ships with an editor-integrated language server, and has Tapioca to auto-generate type stubs for gems and Rails DSLs. RBS is the official Ruby type signature format from matz — it stores signatures in separate .rbs files and has fewer tools. Sorbet is more mature in 2026 and is what I recommend for production Rails apps that want type checking today.

Does sorbet-runtime slow down my Rails app in production?

Yes, measurably. sorbet-runtime wraps every sig-annotated method with a type check that runs on every call. In a Rails request handling 200 method calls, that is 200 extra hash lookups and comparisons. I run sorbet-runtime at the raise level in development and test, and at the log level in production — where violations are logged to your error tracker but do not raise. Some teams disable it entirely in production; that is a defensible choice too.

How do I handle Rails routes and controllers with Sorbet?

Routes are covered by Tapioca’s DSL compiler — bin/tapioca dsl generates path helpers like user_path(user) with proper signatures. Controllers are harder because params returns ActionController::Parameters and Sorbet cannot statically infer what keys are inside. My pattern is to type controller actions at # typed: true, use strong params inside the action, and extract the typed part into a service object that has a proper sig. Do not try to type params[:user][:email] — you will fight the type system forever.

Can I use Rails Sorbet on a Rails 5 or Rails 6 application?

Yes. Sorbet requires Ruby 3.1 or newer (the last few Sorbet releases dropped older Rubies), so your Rails 5/6 app needs to be running on a supported Ruby. Beyond that, the Tapioca DSL compilers cover Rails back to 5.2, and I have introduced Sorbet on production Rails 6.0 apps without issue. Rails 4 is unsupported.

Need help introducing Rails Sorbet into a legacy Rails codebase without burning a quarter of engineering runway on annotations? TTB Software specializes in Rails, gradual migrations, and shipping typed production code that actually catches bugs. We’ve been doing this for nineteen years.

#rails-sorbet #ruby-sorbet-gradual-typing #rails-tapioca-rbi #rails-sorbet-strict-mode #rails-static-type-checking #rails-sorbet-ci

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software