In May 2020 I wrote a ticket for myself: set up Sorbet, a type checker for Ruby. "I keep pushing really basic bugs to production," it said. "Missing constants, missing methods, etc. can all be easily caught by a type checker."
I like Ruby, and DocSpring has been a Rails app since 2017. But Ruby checks nothing until the code runs, and duck typing means a method takes whatever you pass it until it calls something that object doesn't have. Our production error alerts show what that costs. Going back through the alerts Sentry has emailed us since 2017, I counted 131 distinct nil errors: undefined method 'x' for nil, or no implicit conversion of nil into String. Another 127 were the same kind of mistake with other values: a method called on an Integer or a String that doesn't have it, a misspelled variable in a view, a constant that doesn't exist, the wrong number of arguments. That's 258 bugs that reached production, even though we've always maintained a very high level of test coverage. A type checker can report most of these errors and fail CI before the code ships.
That Sorbet ticket waited almost five years, because adding Sorbet types to a large Ruby codebase was an enormous undertaking. When I finally installed Sorbet in April 2025, I typed 15 files and wrote a rule that new files should be strict. Eighteen months later, only about a tenth of our Ruby code was typed.
In October 2026, AI agents typed the rest of our code in four days. Since then, every Ruby file Sorbet checks in our Rails app is typed, and so is every ERB view. Getting there meant building our own Sorbet plugins, converting our HAML views to ERB, and teaching Sorbet to read the templates. In November, a new plugin started reading the facts that Tapioca used to copy out of our code into generated files.
Where this stands, as of 11 November 2026: the Ruby typing migration and our Sorbet build with the RSpec, Capybara, rswag, FactoryBot and ActiveAdmin plugins have been in production use since 24 October. The HAML to ERB conversion shipped on 4 November, and the ERB type check on 8 November. The rails plugin, which replaces most of Tapioca's generated files for our own code, shipped on 11 November, in our Sorbet build 0.6.13583.8.
Fifteen files and a rule
What finally got me started was LogStruct, our structured logging gem for Rails. I used Sorbet there in early 2025, and I liked that the types protected our logs against invalid or missing values. Around the same time, I started bringing Sorbet into the DocSpring app.
I set up Sorbet and Tapioca on 1 April 2025. Tapioca generates RBI files: Ruby Interface files that tell Sorbet about methods it can't see in the source, such as the ones gems and Rails define at runtime. On day one we had RBIs for every gem and for Rails' metaprogramming, and srb tc ran on every CI build from mid-April.
The first code I typed was our JSON schema generator, one of the two areas the 2020 ticket had named. Sorbet lets each file choose how much checking it gets. At typed: true, it reports ordinary type errors. At typed: strict, every method needs a signature and every instance variable a declared type. A file with no sigil is typed: false: Sorbet only checks that its constants resolve. Sorbet's strictness documentation has the details.
When the setup merged, 15 of our roughly 1,000 Ruby files were typed: true or stricter, with 51 method signatures between them. I also added a line to the instructions our coding agents read: use typed: strict for new files.
The rule goes unenforced
Nothing failed a build when a file broke that rule. A new file could have no sigil, or an explicit typed: false, and CI passed.
Coverage barely moved. In September 2025 we typed the code that generates our API clients' tests, which took us from 15 typed files to 81. Around then I considered rewriting the backend in Rust or Go, decided an eight-year-old Rails app with that many models, mailers and jobs wasn't going anywhere, and told myself I'd add Sorbet in more places instead.
I also tried to get our specs typed. The Tapioca-based approach I found needed extends throughout the specs and a regeneration after every change. I wanted Sorbet itself to understand the RSpec DSL, with instant feedback in my editor, and I looked into whether a plugin or a fork could do that. A fork looked possible but like a lot of work and a maintenance burden, so I left it there.
By 1 October 2026, 129 of our 1,262 Ruby files were typed, about 10%. In app/, 356 of 419 files had no sigil at all, and new controllers were still arriving as typed: false.
Four days in October
By then, agents were writing nearly all of our code. That changed the cost of the migration I'd been putting off.
An evaluation of Reek (a Ruby code smell tool) found bugs that would have been type errors under typed: true. So we tried an experiment: mark every unchecked file in app/, lib/ and config/ as typed: true and count the errors. There were 4,657 in 280 files, while 204 files passed without any change. Most errors were "method does not exist", and most of those were gaps in what Sorbet knew about the app, not bugs.
A code quality study I'd asked an agent to run around then concluded that the evidence didn't yet show that typing every file would repay the effort. I decided to go ahead anyway and I started the migration on 20 October.
The agents began with the files that already passed, which needed only a sigil. Then they filled the gaps in Sorbet's knowledge: declarations for methods gems supplied at runtime, the classes our mixins expected, values that could be nil. Then the rest of the application, command-line tooling, database migrations, and the specs. Sorbet ran after every change, and its errors told them what to fix next.
That work found real bugs:
- A timeout value read from an environment variable was a string, and comparing it with a number raised an exception.
- In a controller,
redirect_to templates_path && returnreturned before the redirect happened. - An image request for a template that didn't exist raised
NoMethodErrorinstead of returning a 404. - An admin page looked a record up again by a different column, so a numeric ID gave a 500.
- Copying a cached file attachment called a method that Shrine no longer had.
We added regression tests for the fixes.
On the second day the specs and admin pages hit their DSLs, and the question I'd dropped a year earlier came back. The agent recommended against a fork at first: upstream Sorbet had removed its DSL plugin support in 2021. But the cost of a fork looked different now that agents would do most of the work of maintaining it. So we built a small plugin core into Sorbet and kept the plugins in their own repository.
By 22 October the branch had no Sorbet errors, and I asked the agent to confirm that every file really was typed. It merged on 24 October, three and a half days after it started. The change touched 1,909 files: 977 only gained a sigil, and 700 needed real changes. Before it, 404 of our 2,025 Ruby files were typed; after it, all 2,003 files that Sorbet checks were typed: true or stricter.
Letting Sorbet see the DSLs
Ruby libraries often build their APIs around blocks. In an RSpec example, methods such as let and the helpers supplied by rspec-rails make sense at runtime. ActiveAdmin runs different blocks with different receivers: a registration DSL, a view, or a controller. The checker needs to know which one it's looking at.
Upstream Sorbet has experimental RSpec support, but that didn't cover our combination of spec types, Capybara, rswag, factories, and admin pages. These files were part of the migration too, so we needed a way to give Sorbet the missing context.
We ended up building a large set of Sorbet plugins that can translate these constructs into forms Sorbet already understands. Capybara's scenario becomes an RSpec example. The RSpec plugin supplies the modules each spec type gets. The ActiveAdmin plugin gives a show block its model and view helpers, and an action block its controller context.
FactoryBot is a useful example of why that matters. For create(:user), the plugin knows which model the factory builds. It checks factory, trait, and attribute names, and gives a let that builds a record a return signature. Code that calls that let can then be checked against the model, instead of receiving T.untyped. That support let us check the factory definitions as well.
The plugin generators load the app to discover things such as the registered factories and RSpec configuration, then describe them in RBIs. The plugins themselves rewrite the syntax tree during type checking. (They don't rewrite the Ruby that Rails runs.)
Why we're maintaining a Sorbet build
These plugins are C++ code compiled into our Sorbet build. They're enabled individually in sorbet/config. The fork adds a small plugin API around an existing internal extension mechanism.
That mechanism, SemanticExtensionProvider, is undocumented and unsupported. Upstream's maintainers have said it could be removed. We own the work of keeping our plugin interface working when we upgrade Sorbet; this isn't a supported plugin system anyone can turn on in the upstream release. The fork's README describes that dependency and the patches we carry.
Working on it also found an upstream bug: Sorbet's indexing threads weren't copying the semantic extensions, so a rewriter extension could miss files indexed by a worker thread. Our fix was merged upstream.
For us, maintaining the build has made it possible to check code we were otherwise leaving out. The sorbet gem includes generators from the same plugin commit as the compiled binary, so an application can generate its RBIs with the matching version. We publish the builds through DocSpring's gem source.
Keeping it typed
The April 2025 rule hadn't held without a check, so the migration added one: CI fails a change that adds a Ruby file below its minimum, or weakens a file's sigil. New application files must be typed: strict. Specs and tooling that run without sorbet-runtime have a lower minimum of typed: true.
Since then, the agents have written new files strict because the build fails otherwise. We now have 937 strict Ruby files, up from 354 when the migration merged.
A sigil says that Sorbet checks a file, not that every type in it is precise. T.untyped still appears, mostly inside types such as T::Hash[String, T.untyped] for JSON we haven't described.
HAML was a choice I made for myself
With the Ruby migration done, the views were still outside the type check. I like HAML. View templates are shorter, it uses "significant whitespace" like Python, and I don't have to write closing tags. But during this work I started to wonder whether it was still worth it, now that AI writes all our code.
I was choosing a syntax for a different workflow. Agents write most of our code now and I barely type anything by hand myself. So I want our agents to use tools that catch as much as they can before I review the code.
An indentation mistake in HAML can be valid syntax that puts an element under the wrong parent. In ERB, Herb can parse the HTML alongside the Ruby and report missing or mismatched tags. That doesn't prove the page looks right, but it gives us an earlier check on its structure.
ERB also makes some edits smaller. Wrapping a block in a condition can mean adding an opening and closing Ruby tag, instead of re-indenting everything inside it. But the most important thing is that ERB gave us a path to type-checking the Ruby code in our views.
We converted 514 HAML templates, including views, mailers, and component templates. We built a converter using HAML's own parser, then used our specs to render the HAML and converted ERB with the same locals and block. A comparison normalized attribute order and insignificant whitespace while preserving meaningful spaces and whitespace inside elements such as pre.
We reviewed the remaining differences, including attributes the two renderers serialize differently. The tests also found a real regression after HAML was removed: an admin page called an escaping helper that HAML had made available. We changed it to use ERB::Util.html_escape explicitly.
Herb now runs in our commit hook and CI. We use its linter; automatic formatting stays off for now because whitespace changes can alter a rendered page. The conversion shipped on 4 November.
Checking the view where the error is
There was already work on checking ERB with Sorbet, including sorbet_view. We chose to build a Sorbet plugin that could read the templates directly. We wanted an edit to a template to be checked by srb tc, with the error pointing into that template, without first generating a Ruby copy of it.
The erb_views plugin uses a source-transform hook. HTML becomes spaces, while Ruby stays at the same byte offsets. Sorbet parses that transformed source, and the plugin puts the Ruby in a method on a class representing the template. Templates under app/views and app/components default to typed: strict.
A generated context gives each template the helpers and instance-variable types available from its renderers. To work out those types, the generator follows controller actions, callbacks, and literal render calls. It adds T.reveal_type probes to temporary copies of the code and asks Sorbet what the values are. Components are checked against their component classes; mailer views get mailer contexts.
Partials can declare their inputs next to the code. This is a small synthetic example using the plugin's locals and signature comments:
<%# locals: (title:, count: 0) -%>
<%# sig { params(title: String, count: Integer) } -%>
<p><%= title %>: <%= count + 1 %></p>
Save it as app/views/articles/_card.html.erb. A view that renders it with a string for count gets a type error:
<%= render 'articles/card', title: 'Example', count: 'two' %>
Sorbet reports the error on that line of the ERB file:
Expected `Integer` but found `String("two")` for argument `count`
The plugin turns that literal partial render into a call to a typed method. Sorbet can check that the partial exists, that required locals were passed, and that their names and types match. The public fixtures exercise those failures as well as misspelled model methods, helpers, and instance variables.
There are limits. Computed partial names, collection renders, and renders with blocks don't get this call-site check. A local left out of the signature is T.untyped. The generator also needs help where it can't follow an assignment, using a method signature, T.let, or a type comment in the template.
We added a test-time check of the values views actually receive against the declared context. It catches mismatches on the render paths the tests exercise. It's a shallow check: it checks an array's type, for example, without checking every element. That avoids making a type assertion load a database relation just to inspect it.
The view check shipped on 8 November, with all 610 of our view templates at typed: strict. Typing them surfaced more bugs. A search parameter such as ?q=foo gave a 500 on one list page. Several pages gave a 500 when a parameter that should be a string arrived as a list. GET /folders with no path listed nothing instead of the root folder.
Making the generated context tolerable
Our first implementation put the view contexts in one large RBI. A controller change could produce a noisy diff, and waiting for generation was a separate cost from running the type checker.
We split it into files that mirror the templates and kept only the context each template needs. Partials now declare the methods their callers use from their own locals comments, so adding a call to an existing partial doesn't require copying its signature into another generated file.
Profiling also found time spent in our Ruby generator matching Sorbet's revealed types to expressions. Indexing those results by source position reduced that work. A digest of the inputs lets us skip the views' generator when nothing it depends on has changed.
Fixing the information Tapioca gives us
Tapioca generates many of the RBIs Sorbet uses for Ruby and Rails metaprogramming. Checking views made some gaps in those declarations harder to ignore.
We sent three fixes upstream:
- Reuse the parent controller's helper module. Repeating the full helper list in every controller's RBI meant adding one helper module rewrote files that didn't need their own copy of that information.
- Generate mailer helper modules. Mailers have helper methods too, but the controller compiler didn't describe them. The view checker needed those declarations.
- Explain a dead worker. When a forked worker crashed, the error gave little help. The change explains how to retry with one worker and includes the macOS/PostgreSQL GSSAPI workaround where applicable.
The first two also showed why these changes need tests beyond a full regeneration. An early version of our helper fix behaved differently when asked to generate one controller instead of all of them. We fixed that before sending it upstream.
Reading the facts from the source
Fixing Tapioca's output still left us regenerating it. A review of our merged history from August to early November found 48 commits from CI's Tapioca regeneration step. Most of what they changed was information already written in a source file: a job's perform signature copied onto perform_async, a model's columns copied from the database schema, a controller's helpers copied from its helper calls. Until CI regenerated the RBI, Sorbet saw the old copy.
Our rails plugin reads those facts from the files themselves while Sorbet checks them. It covers our jobs and mailers, controller and mailer helpers, concerns, models (columns from db/structure.sql, associations, scopes, enums and the relation classes), ViewComponent slots, and Shrine uploaders and attachments. Change a job's perform signature, and every perform_async call is checked against the new one as soon as you save the file. No RBI generation step required.
Our other plugins see one file at a time. This one can't, because a job's kind depends on its parent class and a model's columns live in the schema. So we had to do something a little more complicated. The plugin finds a constant's file by Zeitwerk's naming rules, parses it with Prism (the parser Sorbet uses), and reads the schema and the inflections initializer directly. That has a small cost in the editor: Sorbet's language server sees a change to one of those files only when it checks the file that depends on it again.
When the plugin meets code it can't type exactly, it reports an error instead of guessing. Examples are a job whose kind it can't tell, or a scope whose name is a variable. Tapioca still writes what gems add at runtime, such as Devise's helpers and the modules gems mix into our classes. The route helpers moved to a commit hook that regenerates them when the routes change.
To check the replacement, we compared Sorbet's symbol table with Tapioca's RBIs and with the plugin: every method on every class and module, its parameters, and its resolved types. Across 31,132 constants, the only differences that remained were ones we could explain. The plugin adds a few modules of its own. In two uploaders, Tapioca's RBI had the wrong method first. And a helper that overrides a route helper now wins in our views, as it does at runtime, because the plugin follows the order in which Rails includes them.
A review before release found that the plugin could crash Sorbet on code that had syntax errors (e.g. while you were in the middle of typing it). A line like class SomeJob < Base:: is a syntax error, and the plugin tried to read a constant that wasn't there. In srb tc that's an unlikely input, but the language server sees it on every keystroke. The plugin's tests now include files in the middle of an edit.
Building this plugin cut the generated RBIs in sorbet/rbi/dsl from 233,357 lines to 60,559. It also made srb tc faster on our app: on an M-series Mac, the median went from 3.5 to 2.4 seconds over 12 alternating runs, with about 170,000 fewer lines of generated RBIs for Sorbet to read.
Where we are
Ruby files at typed: true or stricter |
Strict | Signatures in app/ and lib/ |
|
|---|---|---|---|
| April 2025, setup merged | 15 of about 1,000 | 6 | 51 |
| 1 October 2026 | 129 of 1,262 | 17 | 183 |
| 24 October 2026, migration merged | All 2,003 that Sorbet checks | 354 | 1,747 |
| 11 November 2026 | All 3,135 that Sorbet checks | 937 | 5,876 |
All 610 ERB templates are checked at typed: strict too.
Sorbet hasn't made nil errors impossible. Two still reached production in the week after the migration merged, and both came from untyped data inside our PDF parsing libraries: the first element of an empty array, and a PDF object that turned out to be missing. Sorbet can't check a value whose type it doesn't know. What it has done is push these errors out to the places where our code reads data from code that isn't typed.
The biggest of those places is our PDF filling engine, and we've almost finished rewriting it in Rust. This will give us types all the way down and faster rendering. It also fixes a long list of minor rendering bugs and edge cases that have been in our backlog for years.
I haven't completely ruled out rewriting our entire backend in Rust either. It's not as crazy as it sounds anymore. AI agents could probably get it done in a few weeks. I was interested to see how DHH used AI to rewrite Campfire in Rust (plus many other languages and frameworks). I'm also keeping an eye on Roundhouse and Spinel (a Ruby AOT compiler from Matz, the creator of Ruby).
We're going to stick with Rails for now, and it feels great to finally have Sorbet type-checking every Ruby file and view template in our codebase. I'm hoping I never see another undefined method 'x' for nil error ever again, but that might be wishful thinking. We still have a lot of untyped gems in our Gemfile.