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:
This creates a basic Gemfile in your project. Now add Sorbet and Tapioca to it by opening the file and including these gems:
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:
After installation completes, initialize Sorbet in your project using Tapioca:
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:
Run Sorbet's type checker on this file:
You should see output indicating no errors were found:
Now execute the program to confirm it runs correctly:
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:
Run Sorbet against this file:
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:
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:
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:
Run Sorbet:
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:
Run Sorbet again:
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:
Run Sorbet again:
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:
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:
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:
Run Sorbet:
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:
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:
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.