# Getting Started with Sorbet

[Sorbet](https://sorbet.org/) is a gradual type checker for Ruby that brings static type safety to your codebase without requiring you to rewrite everything at once. Developed by Stripe and open-sourced in 2019, Sorbet has become the go-to solution for teams looking to catch type errors before runtime while maintaining Ruby's flexibility and expressiveness.


What makes Sorbet particularly compelling is its gradual typing approach. Unlike languages that force type annotations everywhere from day one, Sorbet lets you start small and incrementally add types to your codebase. You can begin with zero annotations and progressively tighten type checking as you gain confidence, making it practical for existing projects with millions of lines of code.

This guide will walk you through setting up Sorbet in a Ruby project, understanding its type system, and leveraging its features to write more maintainable code.

[ad-logs]

## Prerequisites

Before diving into Sorbet, make sure you have Ruby 2.7 or later installed on your machine. This guide assumes you're comfortable with Ruby fundamentals like classes, modules, and basic object-oriented programming concepts. You should also have Bundler available for managing dependencies.

## Setting up your first Sorbet project

Let's create a fresh Ruby project to explore Sorbet's capabilities. Start by setting up a new directory and initializing it:

```command
mkdir sorbet-demo && cd sorbet-demo
```

```command
bundle init
```

This creates a basic `Gemfile` in your project. Now add Sorbet and Tapioca to it by opening the file and including these gems:

```ruby
[label Gemfile]
source 'https://rubygems.org'

[highlight]
gem 'sorbet-runtime'

group :development do
  gem 'sorbet'
  gem 'tapioca', require: false
end
[/highlight]
```

The `sorbet` gem provides the static type checker that analyzes your code, while `sorbet-runtime` gives you the runtime type checking capabilities. Tapioca is the modern tool for generating RBI files for your gems and handling Sorbet setup. Install all three with:

```command
bundle install
```

After installation completes, initialize Sorbet in your project using Tapioca:

```command
bundle exec tapioca init
```

This command performs several tasks automatically. It creates a `sorbet` directory containing configuration files, generates RBI (Ruby Interface) files for your gems in `sorbet/rbi/gems`, and sets up the basic structure Sorbet needs to understand your project's dependencies.

You'll notice a new `sorbet/config` file that controls Sorbet's behavior. The `sorbet/rbi` directory contains type signatures for your installed gems and standard library. RBI files are Sorbet's way of understanding code that doesn't have type annotations, including the Ruby standard library and third-party gems.

Create a simple Ruby file to verify everything works:

```ruby
[label main.rb]
# typed: false
require 'sorbet-runtime'

class Calculator
  extend T::Sig

  sig { params(a: Integer, b: Integer).returns(Integer) }
  def add(a, b)
    a + b
  end
end

calc = Calculator.new
puts calc.add(5, 3)
```

Run Sorbet's type checker on this file:

```command
bundle exec srb tc
```

You should see output indicating no errors were found:

```text
[output]
No errors! Great job.
```

Now execute the program to confirm it runs correctly:

```command
ruby main.rb
```

```text
[output]
8
```

The `# typed: false` comment at the top indicates this file has Sorbet disabled. We'll explore different strictness levels shortly, but starting with `false` helps you ease into type checking gradually.


## Understanding Sorbet's strictness levels

Sorbet introduces the concept of strictness levels through special comments at the top of each file. These levels determine how aggressively Sorbet checks your code, letting you migrate files gradually rather than forcing everything at once.

The available strictness levels are (from most permissive to most strict): `ignore`, `false`, `true`, `strict`, and `strong`. Each level builds on the previous one by enabling additional checks.

When you ran `tapioca init` earlier, it automatically added `# typed: false` to all Ruby files in your project. This tells Sorbet to silence type-related errors in those files. Let's explore the different strictness levels by creating examples that demonstrate what each level enforces.

Start with a basic example at the `true` level:

```ruby
[label user.rb]
# typed: true
require 'sorbet-runtime'

class User
  extend T::Sig

  sig { params(name: String, age: Integer).void }
  def initialize(name, age)
    @name = name
    @age = age
  end

  sig { returns(String) }
  def greeting
    "Hello, my name is #{@name}"
  end

  sig { params(new_age: Integer).void }
  def update_age(new_age)
    @age = new_age
  end
end

# Try to create a user with wrong types
user = User.new(123, "not a number")
```

Run Sorbet against this file:

```command
bundle exec srb tc
```

```text
[output]
user.rb:25: Expected String but found Integer(123) for argument name https://srb.help/7002
    25 |user = User.new(123, "not a number")
                        ^^^
  Expected String for argument name of method User#initialize:
    user.rb:7:
     7 |  sig { params(name: String, age: Integer).void }
                       ^^^^
  Got Integer(123) originating from:
    user.rb:25:
    25 |user = User.new(123, "not a number")
                        ^^^

user.rb:25: Expected Integer but found String("not a number") for argument age https://srb.help/7002
    25 |user = User.new(123, "not a number")
                             ^^^^^^^^^^^^^^
  Expected Integer for argument age of method User#initialize:
    user.rb:7:
     7 |  sig { params(name: String, age: Integer).void }
                                     ^^^
  Got String("not a number") originating from:
    user.rb:25:
    25 |user = User.new(123, "not a number")
                             ^^^^^^^^^^^^^^
Errors: 2
```

At the `true` level, Sorbet checks that method calls match their type signatures. When you pass an `Integer` where a `String` is expected, or vice versa, Sorbet catches the mismatch. Remove the problematic line to fix these errors:

```ruby
[label user.rb]
# typed: true
require 'sorbet-runtime'

class User
  ...
end

[highlight]
//remove the following
# Try to create a user with wrong types
user = User.new(123, "not a number")
[/highlight]
```

Run Sorbet again and you'll see no errors. The `true` level is permissive about methods without signatures - they simply won't be type-checked. This makes it easy to gradually add types to your codebase without requiring signatures on every method immediately.

The `strict` level builds on `true` by adding checks for uninitialized instance variables and certain other type safety issues. For simple classes like our example where all instance variables are set in `initialize`, changing to `# typed: strict` won't show additional errors:

```ruby
[label user.rb]
[highlight]
# typed: strict
[/highlight]
require 'sorbet-runtime'

class User
  ...
end
```

```command
bundle exec srb tc
```

```text
[output]
No errors! Great job.
```

The `strict` level provides a good balance between safety and flexibility for most projects. It catches common mistakes while remaining practical to adopt incrementally.

The highest level, `strong`, goes even further by requiring signatures on all methods and disallowing `T.untyped` entirely. Remove the signature from `update_age` and change to `# typed: strong`:

```ruby
[label user.rb]
[highlight]
# typed: strong
[/highlight]
require 'sorbet-runtime'

class User
  extend T::Sig

  sig { params(name: String, age: Integer).void }
  def initialize(name, age)
    @name = name
    @age = age
  end

  sig { returns(String) }
  def greeting
    "Hello, my name is #{@name}"
  end

  [highlight]
  def update_age(new_age)
    @age = new_age
  end
  [/highlight]

end
```

Run Sorbet:

```command
bundle exec srb tc
```

```text
[output]
user.rb:20: Value returned from method update_age is T.untyped https://srb.help/7018
    20 |    @age = new_age
            ^^^^^^^^^^^^^^
  Got T.untyped originating from:
    user.rb:20:
    20 |    @age = new_age
                   ^^^^^^^
  Note:
    Support for typed: strong is minimal. Consider using typed: strict instead.

user.rb:19: The method update_age does not have a sig https://srb.help/7017
    19 |  def update_age(new_age)
          ^^^^^^^^^^^^^^^^^^^^^^^
Errors: 2
```

At the `strong` level, every method must have a signature and no `T.untyped` values are allowed. As Sorbet itself notes, support for `strong` is minimal and most teams should use `strict` instead.

For new files you create from scratch, start with `# typed: true` or `# typed: strict` and add signatures to methods you want type-checked. For legacy code you're not ready to annotate yet, keep it at `# typed: false` (which is what `tapioca init` sets by default). You can upgrade files individually as you add types, making migration manageable even in large codebases.



### Adding your first type signatures (proper transition)

In the previous section, Sorbet reported errors when we moved to `# typed: strong` because the `update_age` method had **no signature**, and therefore returned a value of `T.untyped`. This is Sorbet telling us:

> “I want full type information here before I can continue.”

Let’s fix that by adding a signature to the method that Sorbet complained about:

```ruby
[label user.rb]
# typed: strong
require 'sorbet-runtime'

class User
  extend T::Sig

  sig { params(name: String, age: Integer).void }
  def initialize(name, age)
    @name = name
    @age = age
  end

  sig { returns(String) }
  def greeting
    "Hello, my name is #{@name}"
  end

  [highlight]
  sig { params(new_age: Integer).void }
  def update_age(new_age)
    @age = new_age
  end
  [/highlight]
end
```

Run Sorbet again:

```command
bundle exec srb tc
```

```text
[output]
No errors! Great job.
```

By adding a `sig` to `update_age`, Sorbet now has complete information about the method’s parameters and its return type. This resolves both of the errors from before.

This is the core workflow when using Sorbet effectively:

1. Increase strictness gradually
2. Let Sorbet tell you what information is missing
3. Add signatures where needed
4. Repeat for other parts of the file

Rather than trying to annotate everything at once, Sorbet guides you toward the next thing it needs proof about — reducing friction during adoption.

In the next section, we’ll go one step further and look at **methods that may accept multiple possible types**, and how Sorbet models that using union types.


## Working with optional and union types

Real-world Ruby code often accepts more than one type for a method argument. For example, you might allow a user's age to be updated using either an `Integer` or a `String` that can be parsed into an integer. Sorbet models this kind of flexibility using union types, represented with `T.any`.

Let's extend our `User` class to support updating the age with either an `Integer` or a `String`:

```ruby
[label user.rb]
# typed: strict
require 'sorbet-runtime'

class User
  extend T::Sig

  sig { params(name: String, age: Integer).void }
  def initialize(name, age)
    @name = name
    @age = age
  end

  sig { returns(String) }
  def greeting
    "Hello, my name is #{@name}"
  end

  [highlight]
  # Accept either an Integer or a String
  sig { params(new_age: T.any(Integer, String)).void }
  def update_age(new_age)
    @age = new_age.is_a?(String) ? new_age.to_i : new_age
  end
  [/highlight]
end
```

Run Sorbet again:

```command
bundle exec srb tc
```

```text
[output]
No errors! Great job.
```

With `T.any(Integer, String)` in place, Sorbet now understands that `new_age` can legally be either type. Inside the method, the `is_a?` check ensures we convert a `String` to an `Integer` before storing it. This is how Sorbet encourages you to make type distinctions explicit instead of relying on implicit Ruby conventions.

You'll frequently encounter another related construct: `T.nilable`. This is a shorthand for `T.any(SomeType, NilClass)`, and it allows a value to be either a specific type or `nil`. Sorbet's handling of `nil` is strict by default, so marking something as nullable is an intentional choice.

Here's a small example:

```ruby
sig { params(nickname: T.nilable(String)).void }
def set_nickname(nickname)
  @nickname = nickname
end
```

Now `nickname` can be either a `String` or `nil`, and Sorbet will treat both as valid.

Union types like `T.any` and `T.nilable` are key to expressing flexible Ruby code safely — they give callers freedom while still allowing Sorbet to reason about every possible case.

In the next section, we'll look at how Sorbet helps the type checker "narrow" these unions automatically using control-flow analysis, also known as type narrowing.


## Type narrowing with flow-sensitive typing

One of Sorbet's most powerful features is its ability to track how types change as your code executes. When you check the type of a variable using conditions like `is_a?` or `nil?`, Sorbet automatically narrows the type in subsequent code paths. This is called flow-sensitive typing.

Let's see this in action with a method that processes different types of input:

```ruby
[label processor.rb]
# typed: true
require 'sorbet-runtime'

class DataProcessor
  extend T::Sig

  sig { params(input: T.any(String, Integer, NilClass)).returns(String) }
  def process(input)
    if input.nil?
      return "No data provided"
    end

    if input.is_a?(String)
      return "Processing string: #{input.upcase}"
    end

    if input.is_a?(Integer)
      return "Processing number: #{input * 2}"
    end

    "Unknown type"
  end
end
```

In this example, `input` starts as a union type `T.any(String, Integer, NilClass)`. After the `nil?` check, Sorbet knows the value can't be nil anymore in the remaining code. After the `is_a?(String)` check, Sorbet narrows the type to `String` within that branch, allowing you to call string methods like `upcase` without additional checks.

Test this behavior by trying to use a method before the type is narrowed:

```ruby
[label test_processor.rb]
# typed: true
require_relative 'processor'

class DataProcessor
  extend T::Sig

  sig { params(input: T.any(String, Integer, NilClass)).returns(String) }
  def process_broken(input)
    # Try to call upcase without checking the type first
    return input.upcase
  end
end
```

Run Sorbet:

```command
bundle exec srb tc
```

```text
[output]
test_processor.rb:10: Method upcase does not exist on Integer component of T.nilable(T.any(String, Integer)) https://srb.help/7003
    10 |    return input.upcase
                         ^^^^^^
  Got T.nilable(T.any(String, Integer)) originating from:
    test_processor.rb:8:
     8 |  def process_broken(input)
                             ^^^^^
  Did you mean phase? Use -a to autocorrect
    test_processor.rb:10: Replace with phase
    10 |    return input.upcase
                         ^^^^^^
    https://github.com/sorbet/sorbet/tree/1d0393b9c70200f4f1ccc392646024bf0a07d959/rbi/core/integer.rbi#L957: Defined here
     957 |  def phase(); end
            ^^^^^^^^^^^

test_processor.rb:10: Method upcase does not exist on NilClass component of T.nilable(T.any(String, Integer)) https://srb.help/7003
    10 |    return input.upcase
                         ^^^^^^
  Got T.nilable(T.any(String, Integer)) originating from:
    test_processor.rb:8:
     8 |  def process_broken(input)
                             ^^^^^
  Autocorrect: Use -a to autocorrect
    test_processor.rb:10: Replace with T.must(input)
    10 |    return input.upcase
                   ^^^^^
Errors: 2
```

Sorbet correctly identifies that `upcase` doesn't exist on `Integer` or `NilClass`. You must narrow the type first before calling type-specific methods.

Flow-sensitive typing also works with early returns and guard clauses, which is a common Ruby pattern:

```ruby
sig { params(user: T.nilable(User)).returns(String) }
def greet_user(user)
  return "No user provided" if user.nil?
  
  # Sorbet knows user is not nil here
  user.greeting
end
```

After the early return, Sorbet understands that `user` can't be nil in the remaining code, so you can safely call methods on it without additional checks or using the safe navigation operator.

You can also use `T.must` when you're certain a value isn't nil but Sorbet can't infer it:

```ruby
sig { params(email: String).returns(User) }
def find_user!(email)
  user = find_user(email)  # Returns T.nilable(User)
  T.must(user)  # Assert it's not nil, or raise
end
```

The `T.must` helper tells Sorbet to trust you that the value won't be nil. If it is nil at runtime, an exception is raised. Use this sparingly - it's better to handle nil explicitly when possible.

Sorbet's flow-sensitive typing makes working with union types and nullable values much more ergonomic than in many other typed languages. You write natural Ruby code with explicit type checks, and Sorbet understands the implications automatically.

## Final thoughts
Sorbet brings static type safety to Ruby in a way that matches how real codebases evolve. You do not need to convert everything at once, and you can adopt it gradually as your project grows in complexity. 

By layering stricter checks over time and adding type signatures where they provide the most value, Sorbet helps catch errors early, improves readability, and makes refactoring safer — all while preserving Ruby’s natural expressiveness. Adopting it in small steps lets you gain the benefits of stronger guarantees without interrupting the way you already write Ruby today.