# Sorbet Type Checking Guide TurboVault uses [Sorbet](https://sorbet.org/) for gradual static type checking. ## Quick Start ### First Time Setup After installing gems, initialize Sorbet: ```bash # 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 configuration - `sorbet/rbi/gems/` - Type definitions for gems (gitignored) - `sorbet/rbi/dsl/` - Generated types for Rails models, etc. (gitignored) ### Running Type Checks ```bash # 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) ```ruby # 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) ```ruby # 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 ```ruby # 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 ```ruby # 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 ```ruby # 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 ```ruby # 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 ```ruby # 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): ```ruby # 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: ```ruby # 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 ```ruby # 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 ```ruby # 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 ```ruby # 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: true` to 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: true` to `typed: strict` - Remove `T.untyped` where possible - Add runtime type checks where needed ## IDE Integration ### VSCode Install the [Sorbet extension](https://marketplace.visualstudio.com/items?itemName=sorbet.sorbet-vscode-extension): 1. Install extension 2. Sorbet will auto-detect your project 3. Get inline type errors and autocomplete ### RubyMine Sorbet support is built-in: 1. Enable Sorbet in Settings → Languages & Frameworks → Ruby 2. RubyMine will use Sorbet for type checking ## Updating Types When you add gems or change models: ```bash # 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()`: ```ruby 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" ```ruby # Wrong MY_CONSTANT = "value" # Right MY_CONSTANT = T.let("value", String) ``` ## Best Practices ### ✅ DO: - Start with `# typed: true` (not strict) - Use `sig` for public methods - Type critical business logic - Use `T.untyped` for 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: strict` on new code (too strict) - Ignore type errors (fix or suppress with comment) - Forget to run `tapioca:dsl` after model changes ## CI Integration Add to `.github/workflows/ci.yml`: ```yaml - name: Type check with Sorbet run: | bundle exec tapioca gems --no-doc bundle exec tapioca dsl bundle exec srb tc ``` ## Resources - [Sorbet Documentation](https://sorbet.org/) - [Sorbet Playground](https://sorbet.run/) - [Tapioca Documentation](https://github.com/Shopify/tapioca) - [T::Types Cheat Sheet](https://sorbet.org/docs/stdlib-generics) ## Current Status **All files start with `# typed: false`** (no checking enabled yet). **Ready to type (after running setup):** - `app/models/user.rb` - `app/models/game.rb` - `app/services/igdb_service.rb` - `app/controllers/games_controller.rb` **Recommended typing order:** 1. Start with Models: User, Game, Platform, Genre 2. Then Services: IgdbService 3. Then Controllers: GamesController, CollectionsController 4. Finally Jobs and Helpers **Important:** Run the setup steps first: ```bash 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!