Back to Scaling Ruby Applications guides

Sorbet vs RBS: Choosing a Type System for Ruby

Stanley Ulili
Updated on October 31, 2025

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?

Screenshot of 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?

Screenshot of RBS Github page

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:

sig/user_service.rbs
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:

app/payment_processor.rb
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
sig/payment_processor.rbs
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:

sig/dynamic_handler.rbs
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:

app/user_repository.rb
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
sig/user_repository.rbs
class UserRepository
  def all_active: () -> Array[User]

  def find: (Integer id) -> User?
end
sig/user.rbs
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:

app/services/legacy_service.rb
# 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
app/services/modern_service.rb
# 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
app/services/strict_service.rb
# 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
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:

app/api_client.rb
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
sig/api_client.rbs
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
Output
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:

app/order_processor.rb
class OrderProcessor
  def process(order)
    total = calculate_total(order)
    "Order total: #{total}"
  end

  def calculate_total(order)
    order.items.sum(&:price)
  end
end
sig/order_processor.rbs
class OrderProcessor
  def process: (Order order) -> String
  def calculate_total: (Order order) -> Integer
end
Output
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
Output
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:

app/payment_service.rb
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
sig/payment_service.rbs
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:

Gemfile
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:

Gemfile
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:

sig/user_service.rbs
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:

app/payment_service.rb
class PaymentService
  def charge(amount)
    # Autocomplete shows all Ruby methods
    # Not filtered by Integer type
    payment = Payment.new(amount: amount)
    payment
  end
end
sig/payment_service.rbs
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.