11 KiB
Sorbet Type Checking Guide
TurboVault uses Sorbet for gradual static type checking.
Quick Start
First Time Setup
After installing gems, initialize Sorbet:
# Install dependencies
bundle install
# Initialize Tapioca (generates type definitions)
task tapioca:init
# Generate RBI files for gems and Rails DSLs
task tapioca:all
This creates:
sorbet/- Sorbet configurationsorbet/rbi/gems/- Type definitions for gems (gitignored)sorbet/rbi/dsl/- Generated types for Rails models, etc. (gitignored)
Running Type Checks
# Check all files
task typecheck
# Watch mode (re-checks on file changes)
task typecheck:watch
# Or use Sorbet directly
bundle exec srb tc
Type Strictness Levels
Sorbet uses gradual typing with 5 strictness levels:
# typed: false (Default - No Checking)
# typed: false
class MyClass
def do_something(x)
x + 1 # No type checking at all
end
end
- Use for: Legacy code, external gems, code you're not ready to type yet
- Checking: None
# typed: true (Recommended)
# typed: true
class User < ApplicationRecord
extend T::Sig
sig { returns(String) }
def theme_class
"theme-#{theme}"
end
end
- Use for: Most application code
- Checking: Method signatures you define with
sig
# typed: strict (Advanced)
- Use for: Critical business logic, libraries
- Checking: All method signatures required, no untyped code
# typed: strong (Expert)
- Use for: Maximum safety
- Checking: No
T.untyped, no runtime checks
Writing Type Signatures
Basic Method Signatures
# typed: true
class Game < ApplicationRecord
extend T::Sig
# No parameters, returns String
sig { returns(String) }
def title
read_attribute(:title)
end
# One parameter, returns Boolean
sig { params(user: User).returns(T::Boolean) }
def owned_by?(user)
self.user_id == user.id
end
# Multiple parameters
sig { params(title: String, platform: Platform).returns(Game) }
def self.create_game(title, platform)
create(title: title, platform: platform)
end
# Void (returns nothing meaningful)
sig { void }
def update_cache
Rails.cache.write("game_#{id}", self)
end
end
Nilable Types
# typed: true
class Game < ApplicationRecord
extend T::Sig
# Can return Platform or nil
sig { returns(T.nilable(Platform)) }
def platform
super
end
# Parameter can be nil
sig { params(platform_id: T.nilable(Integer)).returns(T.untyped) }
def self.by_platform(platform_id)
where(platform_id: platform_id) if platform_id
end
end
Arrays and Hashes
# typed: true
class IgdbService
extend T::Sig
# Array of Hashes
sig { params(title: String).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def search_game(title)
# Returns: [{id: 1, name: "Game"}, {id: 2, name: "Game 2"}]
end
# Array of specific type
sig { returns(T::Array[Game]) }
def all_games
Game.all.to_a
end
# Hash with specific keys
sig { returns(T::Hash[String, Integer]) }
def game_counts
{ "total" => 100, "completed" => 50 }
end
end
Optional Parameters
# typed: true
class IgdbService
extend T::Sig
# Optional parameter with default
sig { params(title: String, limit: Integer).returns(T::Array[T.untyped]) }
def search_game(title, limit = 3)
# ...
end
# Optional keyword parameter
sig { params(title: String, platform: T.nilable(String)).returns(T::Array[T.untyped]) }
def search(title, platform: nil)
# ...
end
end
Instance Variables
# typed: true
class IgdbService
extend T::Sig
sig { void }
def initialize
@client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String)
@client_secret = T.let(ENV.fetch("IGDB_CLIENT_SECRET"), String)
@access_token = T.let(get_or_refresh_token, String)
end
end
T.let(value, Type) declares the type of an instance variable.
Untyped Code
When you can't determine the type (ActiveRecord relations, dynamic code):
# typed: true
class GamesController < ApplicationController
extend T::Sig
sig { void }
def index
# ActiveRecord relations are complex, use T.untyped
@games = T.let(current_user.games.includes(:platform), T.untyped)
end
end
Use T.untyped sparingly - it bypasses type checking.
CSV Rows (Common Pattern)
CSV::Row acts like a Hash but Sorbet doesn't know that:
# typed: true
sig { void }
def bulk_create
csv = CSV.parse(csv_text, headers: true)
csv.each_with_index do |row, index|
# Tell Sorbet row is untyped to avoid Array vs Hash confusion
row = T.let(row, T.untyped)
# Now you can access row["column"] without errors
title = row["title"]
platform = row["platform"]
end
end
This is acceptable because CSV parsing is inherently dynamic and validated by your model.
Common Patterns
Controllers
# typed: true
class GamesController < ApplicationController
extend T::Sig
sig { void }
def index
@games = T.let(current_user.games.all, T.untyped)
end
sig { void }
def show
@game = T.let(current_user.games.find(params[:id]), Game)
end
sig { void }
def create
@game = T.let(current_user.games.build(game_params), Game)
if @game.save
redirect_to @game
else
render :new
end
end
end
Models
# typed: true
class Game < ApplicationRecord
extend T::Sig
# Class methods
sig { params(query: String).returns(T.untyped) }
def self.search(query)
where("title ILIKE ?", "%#{query}%")
end
# Instance methods
sig { returns(String) }
def display_title
"#{title} (#{platform&.name || 'Unknown'})"
end
# Private methods
private
sig { void }
def set_defaults
self.date_added ||= Date.current
end
end
Services
# typed: true
class IgdbService
extend T::Sig
sig { void }
def initialize
@client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String)
@access_token = T.let(get_token, String)
end
sig { params(title: String, platform: T.nilable(String)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def search_game(title, platform: nil)
results = post("/games", build_query(title, platform))
format_results(results)
end
private
sig { params(title: String, platform: T.nilable(String)).returns(String) }
def build_query(title, platform)
"search \"#{title}\";"
end
sig { params(results: T::Array[T.untyped]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def format_results(results)
results.map { |r| { id: r["id"], name: r["name"] } }
end
end
Gradual Adoption Strategy
Phase 1: Infrastructure (Current)
- ✅ Add Sorbet gems
- ✅ Generate RBI files
- ✅ Add
# typed: trueto key files - ✅ Type critical methods (User, Game, IgdbService)
Phase 2: Core Models (Next)
- Add types to all models
- Type associations and scopes
- Type validations and callbacks
Phase 3: Controllers
- Add types to controller actions
- Type params and filters
- Type helper methods
Phase 4: Services & Jobs
- Type all service objects
- Type background jobs
- Type API clients
Phase 5: Increase Strictness
- Move files from
typed: truetotyped: strict - Remove
T.untypedwhere possible - Add runtime type checks where needed
IDE Integration
VSCode
Install the Sorbet extension:
- Install extension
- Sorbet will auto-detect your project
- Get inline type errors and autocomplete
RubyMine
Sorbet support is built-in:
- Enable Sorbet in Settings → Languages & Frameworks → Ruby
- RubyMine will use Sorbet for type checking
Updating Types
When you add gems or change models:
# After adding/updating gems
task tapioca:gems
# After changing models/routes/etc
task tapioca:dsl
# Both at once
task tapioca:all
Run this after:
bundle install(new/updated gems)- Adding/changing models
- Adding/changing routes
- Adding/changing concerns
Common Errors
"Parent of class redefined" (RDoc, etc.)
Parent of class RDoc::Markup::Heading redefined from RDoc::Markup::Element to Struct
Fix: This is a known gem incompatibility. Already fixed in sorbet/config:
--suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading
If you see similar errors for other gems, add suppression flags to sorbet/config.
"Method does not exist"
app/models/game.rb:10: Method `foo` does not exist on `Game`
Fix: Add the method signature or check for typos.
"Argument does not have asserted type"
Expected String but got T.nilable(String)
Fix: Handle nil case or use T.must():
sig { params(name: String).void }
def greet(name)
puts "Hello #{name}"
end
# Wrong
greet(user.name) # name might be nil
# Right
greet(user.name || "Guest")
greet(T.must(user.name)) # Asserts non-nil
"Constants must have type annotations"
# Wrong
MY_CONSTANT = "value"
# Right
MY_CONSTANT = T.let("value", String)
Best Practices
✅ DO:
- Start with
# typed: true(not strict) - Use
sigfor public methods - Type critical business logic
- Use
T.untypedfor complex ActiveRecord queries - Update RBI files after gem/model changes
- Run type checks in CI
❌ DON'T:
- Type everything at once (gradual adoption!)
- Use
# typed: stricton new code (too strict) - Ignore type errors (fix or suppress with comment)
- Forget to run
tapioca:dslafter model changes
CI Integration
Add to .github/workflows/ci.yml:
- name: Type check with Sorbet
run: |
bundle exec tapioca gems --no-doc
bundle exec tapioca dsl
bundle exec srb tc
Resources
Current Status
All files start with # typed: false (no checking enabled yet).
Ready to type (after running setup):
app/models/user.rbapp/models/game.rbapp/services/igdb_service.rbapp/controllers/games_controller.rb
Recommended typing order:
- Start with Models: User, Game, Platform, Genre
- Then Services: IgdbService
- Then Controllers: GamesController, CollectionsController
- Finally Jobs and Helpers
Important: Run the setup steps first:
bundle install
task tapioca:init
task tapioca:all
task typecheck # Should show "No errors!"
Then start adding # typed: true and signatures incrementally.
Happy typing! 🎉 Start small, add types gradually, and enjoy better IDE support and bug catching!