Files
turbovault-app/docs/SORBET.md
2026-03-29 02:37:49 -04:00

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 configuration
  • sorbet/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
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: 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:

  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:

# 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 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:

- 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.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:

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!