Sorbet vs RBS: Choosing a Type System for Ruby
Ruby's type system landscape offers two competing approaches. Sorbet brings runtime checks and gradual adoption to existing codebases, while RBS provides pure static analysis through separate signature files. This distinction changes how you introduce types into your workflow, not just which syntax you write.
Sorbet emerged from Stripe in 2019 to catch type errors in massive Ruby applications without blocking deployments. The tool runs as part of your development cycle, offering instant feedback through editor integration. RBS arrived with Ruby 3.0 in 2020 as the language's official type signature format, designed for static analysis tools to build upon.
Modern Ruby projects increasingly adopt type checking to prevent bugs and improve developer experience. Sorbet integrates directly into your Ruby files using special comments and runtime assertions. RBS separates type information into .rbs files that sit alongside your implementation code. Your decision affects tooling, migration strategy, and how your team thinks about types.
What is Sorbet?
Sorbet adds type annotations directly to Ruby code using special comment syntax. You mark files with typed strictness levels, declare method signatures inline, and run the type checker as part of your workflow. The system understands Ruby's dynamic features while catching type mismatches before runtime.
The gradual typing model lets you adopt types incrementally. Start by running Sorbet on existing code with minimal changes. Add signatures to critical methods as you modify them. Increase strictness levels file by file when you're ready for stronger guarantees. The type checker works with whatever level of annotation you provide.
Sorbet operates in two modes: static and runtime. The static type checker analyzes code without executing it, finding errors during development or CI. Runtime checks validate that method calls and returns match declared types, catching mismatches that slip through static analysis. You configure both modes independently based on your needs.
Type annotations use Ruby method calls that look like comments to other tools:
The # typed: true comment tells Sorbet this file should be type-checked. Method signatures declare parameter types and return values. The type checker verifies that implementations match signatures and that callers provide correct types.
What is RBS?
RBS takes the opposite approach. Types live completely separate from your implementation. You write .rbs files containing class definitions, method signatures, and type aliases. Static analysis tools read these signatures to check your implementation code for type safety.
The separation between code and types means your Ruby files remain unchanged. No special comments or syntax clutter your implementation. Type signatures live in their own directory structure, mirroring your source code organization. Tools like Steep and TypeProf consume RBS files to perform type checking.
RBS uses a syntax inspired by TypeScript and other typed languages. Classes, modules, and methods get declared with explicit type information. The format supports generics, union types, and complex type relationships. Ruby's standard library ships with RBS signatures included.
Type definitions declare structure without implementation:
The .rbs file describes what methods exist and what types they accept. Your actual Ruby implementation stays clean. Type checkers verify that your code matches these signatures without requiring changes to working Ruby files.
Sorbet vs RBS: quick comparison
| Aspect | Sorbet | RBS |
|---|---|---|
| Type location | Inline with code | Separate .rbs files |
| Adoption path | Gradual per-file strictness | Write signatures separately |
| Runtime validation | Built-in with T.must, T.cast |
Requires separate gems |
| Editor support | Strong LSP integration | Growing tool support |
| Standard library | Custom signatures included | Ships with Ruby 3.0+ |
| Syntax style | Ruby method calls | Custom declaration syntax |
| Tooling ecosystem | Sorbet-focused workflow | Multiple tools (Steep, TypeProf) |
| Type inference | Extensive automatic inference | Limited, manual signatures |
| Migration effort | Add comments to files | Write parallel signature files |
| Community backing | Stripe, Shopify production use | Ruby core team official format |
The inline versus separate file tradeoff
That fundamental split (types in your code versus types in separate files) shapes everything else. When I first tried Sorbet, the inline signatures felt natural because I could see types right where I needed them:
Everything lives in one file. The signature sits above the method it describes. Running srb tc validates the whole thing. But those inline signatures add visual noise. Each method now has two blocks of code to maintain.
Then I tried the same feature with RBS, and the separation became immediately obvious:
The Ruby file looks exactly like Ruby. Clean implementation without type annotations cluttering the logic. But now I'm jumping between two files constantly. Change a method signature? Update both files. Add a parameter? Remember to modify the .rbs file too.
How Ruby's dynamic nature complicates both approaches
That clean separation in RBS started breaking down when I hit Ruby's metaprogramming patterns. Sorbet handles dynamic code through runtime helpers that explicitly mark the escape hatches:
The T.cast method performs runtime type assertions. T.must converts nilable types to non-nil with a runtime check. T.untyped escapes the type system when dealing with metaprogramming or legacy code. These helpers make the dynamic patterns explicit. You can see exactly where type safety ends.
RBS handles the same scenarios differently, purely through signature flexibility:
Union types document that methods accept multiple types. The untyped keyword marks where checking stops. Method overloading declares multiple valid signatures. But here's what I noticed: without runtime checks, these are just documentation. If your code passes the wrong type at runtime, RBS won't catch it. Sorbet's runtime mode would raise a TypeError immediately.
Type inference reveals different design decisions
Those runtime checks made me curious about how much manual typing I'd actually need. Sorbet surprised me by inferring types aggressively from context:
The type checker follows control flow to narrow types. After an if check, it knows a variable isn't nil. Method return types propagate through chains of calls. Local variables get inferred from assignments. I only needed to annotate method signatures. Everything inside the methods just worked.
Then I tried building the same code with RBS, and hit a wall. Inference doesn't exist:
Every method needs a signature in the RBS file. No inference happens across file boundaries. I had to explicitly declare types for all public APIs and the methods they depend on. That verbosity ensures precision but tripled my typing workload. Sorbet let me focus on API boundaries while it figured out the internal types.
Gradual adoption becomes migration strategy
The inference difference directly affects how you introduce types to existing projects. Sorbet's strictness levels let you ramp up file by file:
I started with # typed: false on my entire 50,000 line Rails app. Added # typed: true to the payment processing service. When that worked, I moved to # typed: strict on new code. Each file progressed independently. The whole migration took weeks instead of months because I could deploy incrementally.
RBS doesn't have strictness levels. You either have signatures or you don't:
TypeProf generated skeleton signatures for my existing code. But the guesses were rough. I spent days refining them to match actual behavior. The tool doesn't know when types are right or wrong until you run the checker. There's no middle ground between "no types" and "full signatures." That all-or-nothing approach made the migration feel riskier.
Gem dependencies expose ecosystem maturity
Those migration challenges paled compared to dealing with third-party gems. My Rails app uses 87 gems. Sorbet needs signatures for all of them:
The sorbet-typed repository had signatures for 73 of my 87 gems. I added it as a submodule and instantly got autocomplete for Sidekiq, Devise, Pundit, and dozens more. The remaining 14 gems I either typed myself in sorbet/rbi/ or marked as T.untyped. Took me three days to get everything working.
RBS's gem situation felt rougher:
Ruby's standard library ships with RBS signatures. That part worked great. But only 12 of my third-party gems had RBS files. I spent two weeks writing signatures for popular gems like Pundit and Pagy that thousands of people use. The ecosystem is catching up, but Sorbet's five-year head start shows.
Development workflow determines daily experience
Those missing gem signatures directly affected my daily coding flow. With Sorbet, my editor became significantly smarter:
The type checker runs fast enough for interactive use. Watch mode updates instantly on file saves. My editor showed red squiggles under type errors as I typed them. Autocomplete suggested methods based on inferred types. When I typed user., my editor listed only User methods, not every possible String or Integer method.
The RBS tooling felt fragmented by comparison:
The tooling ecosystem splits across three separate gems. Each one handles a different part of what Sorbet does in one package. Editor support exists through Steep's LSP, but autocomplete and type inference felt limited. I found myself jumping between .rb and .rbs files constantly because the editor couldn't show me type information in context.
Error messages matter when debugging type issues
Those workflow differences became critical when tracking down errors. Sorbet's messages pointed exactly to the problem:
The error showed me exactly which expression returned the wrong type and traced it back to the source. I clicked through to line 13, saw the sum returning Integer, and understood immediately. Fixed in 30 seconds.
RBS errors through Steep felt less helpful:
The error pointed to line 4, but didn't explain where the Integer came from. I had to manually trace back through the code to find calculate_total. Then I checked the .rbs file to see its signature. The error was correct but less actionable. Took me two minutes to track down what Sorbet showed instantly.
Runtime safety provides a development safety net
Those debugging experiences made me appreciate Sorbet's runtime validation. The static checker catches most issues, but runtime checks catch the rest:
I had a bug where external API data sometimes sent amounts as strings. Sorbet's static checker didn't catch it because the API call was typed as T.untyped. But in development, the first time I hit that code path, the runtime check raised TypeError: Expected Integer, got String. I found and fixed the bug before deploying.
RBS has no runtime checking at all:
The signatures document expected types but don't enforce them. That same bug with string amounts would have shipped to production. I'd have found it only when customers complained about payment failures. RBS assumes your static analysis is comprehensive, but in a language as dynamic as Ruby, that's a risky assumption.
Production usage reveals different risk profiles
That runtime safety gap influenced which companies bet on each tool. Sorbet powers some of Ruby's largest applications:
Stripe runs Sorbet on millions of lines of production code. Shopify has used it for years. When I deployed my Sorbet-typed app, I was following a well-worn path. The tooling is mature. The edge cases are documented. The community can answer weird questions because they've hit them before.
RBS carries the Ruby core team's blessing but less production mileage:
Ruby core backing gives RBS long-term viability. The standard library ships with complete signatures. But I couldn't find many companies running RBS in production at scale. The tooling improves monthly, but it hasn't endured years of production stress testing like Sorbet. Adopting RBS felt like betting on potential rather than proven reliability.
Team learning curves shape adoption success
The maturity gap affects how quickly teams can adopt each tool. Sorbet's Ruby-like syntax helped my teammates ramp up quickly:
The syntax uses Ruby method calls and blocks. My teammates who knew Ruby picked it up in an afternoon. The type syntax reads naturally. The gradual strictness levels let junior developers start with # typed: true while senior developers pushed toward # typed: strict. Everyone learned at their own pace.
RBS required teaching a completely new syntax:
The arrow syntax confused people. Generics looked foreign to Ruby developers. Structural types (interfaces) introduced concepts Ruby doesn't have. My team needed a week to feel comfortable writing signatures. The separation between implementation and types required new mental models about where to look for information.
Editor integration determines daily friction
Those learning curves affected how much the tools helped versus hindered daily work. Sorbet's editor support eliminated friction I didn't know existed:
Autocomplete became type-aware. When I typed amount., my editor showed only Integer methods. No more scrolling past irrelevant String or Array methods. Go-to-definition worked reliably. Find-references included call sites, not just text matches. The editor felt like it understood my code's structure instead of just matching text patterns. VSCode, RubyMine, Vim, and Emacs all support Sorbet through LSP.
RBS editor support felt more limited:
My editor highlighted RBS syntax but didn't connect it to my Ruby code. Autocomplete treated everything as untyped. Hovering over variables showed nothing. The separation between signatures and implementation meant the editor couldn't leverage type information. I was writing types but not getting the workflow benefits that make types worthwhile.
Final thoughts
Both Sorbet and RBS need regular upkeep. When you change a method in your code, update the matching type signatures. Keep your type files and submodules up to date. Watch how type checking affects CI speed, and make sure your team understands how to use type levels and syntax.
With RBS, update the .rbs files whenever your code changes or you add new gems. You’ll also need to learn and use tools like rbs for checking signatures, steep for type checking, and typeprof for generating types from code.
Most teams end up using both tools: Sorbet for app code where developer speed and comfort matter, and RBS for shared libraries that others will use. Choose what fits your team’s workflow, project size, and long-term goals.