Getting Started with Sorbet
Sorbet 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.
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:
mkdir sorbet-demo && cd sorbet-demo
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:
source 'https://rubygems.org'
gem 'sorbet-runtime'
group :development do
gem 'sorbet'
gem 'tapioca', require: false
end
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:
bundle install
After installation completes, initialize Sorbet in your project using Tapioca:
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:
# 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:
bundle exec srb tc
You should see output indicating no errors were found:
No errors! Great job.
Now execute the program to confirm it runs correctly:
ruby main.rb
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:
# 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:
bundle exec srb tc
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:
# typed: true
require 'sorbet-runtime'
class User
...
end
//remove the following
# Try to create a user with wrong types
user = User.new(123, "not a number")
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:
# typed: strict
require 'sorbet-runtime'
class User
...
end
bundle exec srb tc
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:
# 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
def update_age(new_age)
@age = new_age
end
end
Run Sorbet:
bundle exec srb tc
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:
# 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
sig { params(new_age: Integer).void }
def update_age(new_age)
@age = new_age
end
end
Run Sorbet again:
bundle exec srb tc
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:
- Increase strictness gradually
- Let Sorbet tell you what information is missing
- Add signatures where needed
- 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:
# 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
# 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
end
Run Sorbet again:
bundle exec srb tc
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:
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:
# 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:
# 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:
bundle exec srb tc
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:
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:
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.