# 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?

![Screenshot of Sorbet](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/95431025-edb2-4d67-fff4-4398b4405200/orig =1200x600)

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:

```ruby
# 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](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/e1c07f9e-71d8-4fbd-e300-29d3fcb11900/md2x =1200x600)

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:

```ruby
[label 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:

```ruby
# 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:

```ruby
[label 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
```

```ruby
[label 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:

```ruby
# 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:

```ruby
[label 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:

```ruby
# 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:

```ruby
[label 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
```

```ruby
[label sig/user_repository.rbs]
class UserRepository
  def all_active: () -> Array[User]
  
  def find: (Integer id) -> User?
end
```

```ruby
[label 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:

```ruby
[label 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
```

```ruby
[label 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
```

```ruby
[label 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:

```command
rbs prototype rb app/models/user.rb > sig/app/models/user.rbs
```

```ruby
[label 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
```

```command
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:

```ruby
# 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:

```ruby
[label 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
```

```ruby
[label 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:

```command
srb init
```

```command
srb tc
```

```command
srb tc --watch
```

```command
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:

```command
gem install steep
```

```command
steep init
```

```command
steep check
```

```command
rbs prototype rb lib/my_class.rb > sig/my_class.rbs
```

```command
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:

```ruby
# 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
```

```text
[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:

```ruby
[label 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
```

```ruby
[label sig/order_processor.rbs]
class OrderProcessor
  def process: (Order order) -> String
  def calculate_total: (Order order) -> Integer
end
```

```text
[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:

```ruby
# 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
```

```text
[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:

```ruby
[label 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
```

```ruby
[label 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:

```ruby
[label Gemfile]
gem 'sorbet', group: :development
gem 'sorbet-runtime'
gem 'sorbet-static-and-runtime', require: false
```

```command
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:

```ruby
[label Gemfile]
gem 'rbs'
gem 'steep'
gem 'typeprof'
```

```ruby
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:

```ruby
# 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:

```ruby
[label 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:

```ruby
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:

```ruby
[label 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
```

```ruby
[label 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.