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:
# typed: true
class UserService
extend T::Sig
sig { params(email: String).returns(T.nilable(User)) }
def find_by_email(email)
User.where(email: email).first
end
sig { params(user: User, role: Symbol).void }
def assign_role(user, role)
user.update(role: role)
end
end
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:
class UserService
def find_by_email: (String email) -> User?
def assign_role: (User user, Symbol role) -> void
end
# Union types and generics
type user_id = Integer | String
class Repository[Model]
def find: (user_id id) -> Model?
def save: (Model record) -> bool
end
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:
# typed: strict
class PaymentProcessor
extend T::Sig
sig { params(amount: Integer, currency: String).returns(Transaction) }
def charge(amount, currency)
transaction = Transaction.create!(
amount: amount,
currency: currency,
status: :pending
)
process_payment(transaction)
transaction
end
private
sig { params(transaction: Transaction).returns(Transaction) }
def process_payment(transaction)
# Implementation
transaction
end
end
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:
class PaymentProcessor
def charge(amount, currency)
transaction = Transaction.create!(
amount: amount,
currency: currency,
status: :pending
)
process_payment(transaction)
transaction
end
private
def process_payment(transaction)
# Implementation
transaction
end
end
class PaymentProcessor
def charge: (Integer amount, String currency) -> Transaction
private
def process_payment: (Transaction transaction) -> Transaction
end
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:
# typed: strict
class DynamicHandler
extend T::Sig
sig { params(input: T.any(String, Integer)).returns(String) }
def normalize(input)
# T.cast asserts type at runtime
case input
when String
input.upcase
when Integer
input.to_s
else
T.absurd(input) # Proves exhaustiveness
end
end
sig { params(value: T.untyped).returns(User) }
def unsafe_lookup(value)
# T.must asserts non-nil with runtime check
result = legacy_find(value)
T.must(result)
end
sig { params(data: T::Hash[Symbol, T.untyped]).void }
def process_dynamic(data)
# T.untyped opts out of checking
data.each do |key, value|
send("handle_#{key}", value)
end
end
end
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:
class DynamicHandler
# Union types for multiple possibilities
def normalize: (String | Integer input) -> String
# Untyped for anything
def unsafe_lookup: (untyped value) -> User
# Generic hashes with untyped values
def process_dynamic: (Hash[Symbol, untyped] data) -> void
# Method overloading for different signatures
def fetch: (String key) -> String?
| (String key, String default) -> String
end
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:
# typed: true
class UserRepository
extend T::Sig
sig { returns(T::Array[User]) }
def all_active
# Sorbet infers result is Array[User] from signature
users = User.where(active: true).to_a
# Knows 'user' is User from array type
users.map do |user|
user.name.upcase # Knows name returns String
end
users
end
sig { params(id: Integer).returns(T.nilable(User)) }
def find(id)
# Infers query result type
result = User.find_by(id: id)
# Flow-sensitive typing after nil check
if result
# Sorbet knows result is User (not nilable) here
result.email.downcase
end
result
end
end
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:
class UserRepository
def all_active
users = User.where(active: true).to_a
users.map { |user| user.name.upcase }
users
end
def find(id)
result = User.find_by(id: id)
result.email.downcase if result
result
end
end
class UserRepository
def all_active: () -> Array[User]
def find: (Integer id) -> User?
end
class User
attr_reader name: String
attr_reader email: String
def self.where: (Hash[Symbol, untyped] conditions) -> ActiveRecord::Relation
def self.find_by: (Hash[Symbol, untyped] conditions) -> User?
end
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:
# typed: false - no checking, just parse for errors
class LegacyService
def complex_method(input)
# Dynamic Ruby, no types needed yet
result = input.send(calculate_method_name(input))
transform(result)
end
end
# typed: true - basic type checking
class ModernService
extend T::Sig
sig { params(user_id: Integer).returns(T.nilable(User)) }
def find_user(user_id)
User.find_by(id: user_id)
end
# Untyped methods still work
def legacy_helper
# Not checked
end
end
# typed: strict - all methods must have signatures
class StrictService
extend T::Sig
sig { params(email: String).void }
def send_notification(email)
# Everything typed and checked
NotificationMailer.deliver(to: email)
end
end
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:
rbs prototype rb app/models/user.rb > sig/app/models/user.rbs
class User < ApplicationRecord
# TypeProf guessed these, refine as needed
attr_accessor name: String
attr_accessor email: String
def self.find_by_email: (String email) -> User?
def premium?: () -> bool
end
steep check
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:
# typed: strict
require 'sorbet-runtime'
require 'httparty' # Needs sorbet-typed gem
class ApiClient
extend T::Sig
sig { params(endpoint: String).returns(T::Hash[String, T.untyped]) }
def fetch(endpoint)
# HTTParty types come from sorbet-typed community repo
response = HTTParty.get("https://api.example.com/#{endpoint}")
response.parsed_response
end
end
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:
require 'net/http' # Has RBS signatures in Ruby 3.0+
require 'json' # Also has signatures
class ApiClient
def fetch(endpoint)
uri = URI("https://api.example.com/#{endpoint}")
response = Net::HTTP.get_response(uri)
JSON.parse(response.body)
end
end
class ApiClient
def fetch: (String endpoint) -> Hash[String, untyped]
end
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:
srb init
srb tc
srb tc --watch
bundle exec srb tc --ignore-untyped
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:
gem install steep
steep init
steep check
rbs prototype rb lib/my_class.rb > sig/my_class.rbs
rbs validate
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:
# typed: strict
class OrderProcessor
extend T::Sig
sig { params(order: Order).returns(String) }
def process(order)
total = calculate_total(order)
"Order total: #{total}"
end
sig { params(order: Order).returns(Integer) }
def calculate_total(order)
order.items.sum(&:price)
end
end
app/order_processor.rb:8: Expected String but found Integer for method result type
8 | total = calculate_total(order)
^^^^^^^^^^^^^^^^^^^^^
Got Integer originating from:
app/order_processor.rb:13:
13 | order.items.sum(&:price)
^^^^^^^^^^^^^^^^^^^^
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:
class OrderProcessor
def process(order)
total = calculate_total(order)
"Order total: #{total}"
end
def calculate_total(order)
order.items.sum(&:price)
end
end
class OrderProcessor
def process: (Order order) -> String
def calculate_total: (Order order) -> Integer
end
app/order_processor.rb:4:4: [error] Type mismatch
expected: String
actual: Integer
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:
# typed: true
class PaymentService
extend T::Sig
sig { params(amount: Integer, method: String).returns(Payment) }
def charge(amount, method)
# Runtime validation happens automatically
T.let(amount, Integer) # Explicit runtime check
payment = Payment.new(amount: amount)
# T.must asserts non-nil at runtime
result = process_with_gateway(payment)
T.must(result)
end
sig { params(payment: Payment).returns(T.nilable(Payment)) }
def process_with_gateway(payment)
# Implementation might return nil
gateway.charge(payment)
end
end
TypeError: Expected Integer, got String
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:
class PaymentService
def charge(amount, method)
payment = Payment.new(amount: amount)
result = process_with_gateway(payment)
# No automatic runtime validation
# Must add your own checks
raise TypeError unless result.is_a?(Payment)
result
end
def process_with_gateway(payment)
gateway.charge(payment)
end
end
class PaymentService
def charge: (Integer amount, String method) -> Payment
def process_with_gateway: (Payment payment) -> Payment?
end
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:
gem 'sorbet', group: :development
gem 'sorbet-runtime'
gem 'sorbet-static-and-runtime', require: false
git submodule add https://github.com/sorbet/sorbet-typed.git sorbet/rbi/sorbet-typed
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:
gem 'rbs'
gem 'steep'
gem 'typeprof'
require 'rbs'
loader = RBS::EnvironmentLoader.new
loader.add(library: 'pathname')
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:
# typed: true
class UserService
extend T::Sig # Only new concept
# Signatures look like Ruby
sig { params(email: String).returns(T.nilable(User)) }
def find(email)
User.find_by(email: email)
end
# Type syntax is readable
sig do
params(
users: T::Array[User],
roles: T::Set[Symbol]
).returns(T::Hash[Symbol, T::Array[User]])
end
def group_by_role(users, roles)
# Implementation
end
end
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:
class UserService
# Different from Ruby syntax
def find: (String email) -> User?
# Generics look unfamiliar
def group_by_role: (Array[User] users, Set[Symbol] roles) -> Hash[Symbol, Array[User]]
# Type aliases and interfaces
type user_id = Integer | String
interface _Notifiable
def notify: (String message) -> void
end
end
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:
class PaymentService
extend T::Sig
sig { params(amount: Integer).returns(Payment) }
def charge(amount)
# Editor knows 'amount' is Integer
# Autocomplete shows Integer methods, not String methods
# Hovering shows "Integer" in tooltip
payment = Payment.new(amount: amount)
# Cmd+click jumps to Payment class definition
# Find references shows all charge calls site-wide
payment
end
end
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:
class PaymentService
def charge(amount)
# Autocomplete shows all Ruby methods
# Not filtered by Integer type
payment = Payment.new(amount: amount)
payment
end
end
class PaymentService
def charge: (Integer amount) -> Payment
end
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.