mirror of
https://github.com/ryankazokas/turbovault-app.git
synced 2026-04-16 22:12:53 +00:00
521 lines
11 KiB
Markdown
521 lines
11 KiB
Markdown
# 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!
|