Rails Sorbet: Geleidelijke typeveiligheid toevoegen aan legacy Rails-applicaties met sig, tapioca en CI
Rails Sorbet gids: voeg geleidelijke typeveiligheid toe aan legacy Rails-apps met sig, tapioca RBI-generatie, srb tc, strictness-niveaus en CI-integratie.
Een SaaS-klant dumpte in april een negen jaar oude Rails 6.1-codebase op mijn bureau met de vraag uit te zoeken waarom een refactor van twee regels steeds de salarisberekeningen brak voor een subset van hun enterprise-klanten. De boosdoener was een methode calculate_gross(period, employee, opts = {}) en de opts-hash werd door elf lagen businesslogica gesluisd voordat hij in een belastingmodule landde. Niemand in het team kon me vertellen welke keys opts accepteerde. Niemand kon me vertellen wat period was — een Date, een DateRange, een String, een symbol als :month? Alle vier werden vanuit verschillende call sites doorgegeven. De bug was dat één aanroeper Date.today.beginning_of_month..Date.today.end_of_month doorgaf en de belastingmodule .beginning_of_month op het range-object aanriep. Rails Sorbet had dit binnen enkele seconden gemeld. In plaats daarvan kostte het me twee middagen en een git blame-sessie die aan het licht bracht dat de oorspronkelijke auteur het bedrijf in 2019 al had verlaten.
Na negentien jaar Rails ben ik eindelijk gestopt met typeannotaties af te doen als een Java-tic en ben ik ze gaan uitrollen in elke langlevende Rails-app die ik aanraak. Rails Sorbet is de pragmatische keuze — het is wat Stripe bouwde voor hun eigen Ruby-monoliet van een miljoen regels, het is wat Shopify, Chime en Coinbase in CI draaien, en anders dan RBS (het officiële Ruby type signature-formaat) heeft het een werkend ecosysteem, een volwassen editor-ervaring en tooling die het merendeel van de annotaties voor je genereert. Dit stuk gaat over hoe ik Rails Sorbet introduceer in een legacy Rails-codebase zonder een opstand uit te lokken, wat je als eerste moet typeren, en hoe je het in CI draaiende houdt zonder van elke PR een annotatie-marathon te maken.
Waarom Rails Sorbet en niet RBS
De Ruby-community heeft twee typesystemen. RBS is het officiële, gezegend door matz, en woont in .rbs-bestanden naast je .rb-bestanden. Sorbet is de door Stripe gebouwde variant, gebruikt inline sig { ... }-annotaties, en bestaat drie jaar langer dan RBS. Beide controleren types statisch. Geen van beide verandert runtime-gedrag tenzij je expliciet de runtime-checker aanzet.
Ik draai Rails Sorbet om vier redenen die de afgelopen drie jaar niet zijn veranderd:
Sorbet heeft Tapioca. Tapioca (van Shopify) genereert automatisch typestubs — RBI-bestanden genoemd — voor je gems, je ActiveRecord-modellen, je Rails-routes, je DSL’s en de hele meta-programming-laag van Rails. Zonder Tapioca zou je met de hand signatures schrijven voor elk ActiveRecord-attribuut, elke associatie, elke helper-methode. Met Tapioca draai je bin/tapioca gem en krijg je 40.000 regels gegenereerde RBI in een directory waar je nooit meer naar kijkt.
Sorbet’s editor-integratie werkt. De Sorbet language server plugt in VS Code, RubyMine, Neovim en Zed. Jump-to-definition werkt door gems heen. Autocomplete kent het return type van User.find. Hover toont de signature van Money#format. RBS’s language server bestaat, maar loopt jaren achter in polish en dekking.
Sorbet heeft strictness-niveaus. Elk bestand heeft bovenaan een # typed:-sigil — false, true, strict of strong — en je migreert bestanden één voor één van false naar true naar strict. Dat is het hele idee van gradual typing. RBS behandelt alles standaard als untyped, zonder knop per bestand.
Sorbet is battle-tested op schaal. Stripe draait het op 20 miljoen regels Ruby. Welk raar Rails-patroon je ook hebt, iemand bij Stripe heeft er al mee gevochten en het opgelost of de workaround gedocumenteerd.
RBS is de toekomst in de zin dat het uiteindelijk de community-standaard zou kunnen worden, maar Sorbet is het heden in de zin dat je het maandag kunt adopteren en vrijdag echte typecontrole naar CI kunt shippen. Ik kies het gereedschap dat vandaag werkt.
Rails Sorbet installeren in een bestaande applicatie
Voeg drie gems toe en initialiseer:
# Gemfile
group :development do
gem "sorbet", require: false
gem "tapioca", require: false
end
group :development, :test do
gem "sorbet-runtime"
end
sorbet is de type-checker binary. sorbet-runtime levert de sig-DSL en de optionele runtime-checks. tapioca genereert RBI-bestanden voor je gems en Rails-DSL’s. Dan:
bundle install
bin/tapioca init
tapioca init maakt sorbet/config, een sorbet/rbi/-directoryboom aan en voegt een # typed: false-sigil toe aan elk bestaand .rb-bestand in je app. Elk bestand begint untyped, en dat is precies het punt — niets van wat je al hebt breekt, en je kiest zelf bestand voor bestand voor controle.
Genereer daarna RBI voor je gems:
bin/tapioca gem
Dit duurt een paar minuten op een grote Gemfile omdat Tapioca elke gem geïsoleerd boot om het API-oppervlak te extraheren. Je eindigt met sorbet/rbi/gems/rails@8.0.0.rbi en soortgelijke bestanden. Commit ze — ze zijn onderdeel van je bron van waarheid over hoe de API’s van je gems eruitzien.
Voor Rails-specifieke meta-programming (ActiveRecord-attributen, routes, associaties, mailers, jobs), draai je:
bin/tapioca dsl
Dit inspecteert je modellen en genereert RBI-bestanden die bijvoorbeeld verklaren dat User#email een String retourneert en User#posts een ActiveRecord::Relation[Post]. Draai bin/tapioca dsl opnieuw wanneer je een migratie toevoegt of een model wijzigt. In de praktijk hang je dit aan een git-hook of een bin-script.
Draai nu de checker:
bin/srb tc
Je ziet nul fouten. Elk bestand staat op # typed: false, dus Sorbet controleert alleen of je RBI-bestanden intern consistent zijn.
Je eerste sig schrijven
Kies één bestand. Geen model — modellen hebben te veel door Rails gegenereerde methodes. Kies een plain old Ruby object in app/services/ of app/lib/. Iets met een klein publiek oppervlak, weinig aanroepers, en geen metaprogramming. Dat is je eerste kandidaat.
Verander de sigil bovenaan het bestand van # typed: false naar # typed: true en voeg signatures toe:
# typed: true
# frozen_string_literal: true
class PayrollCalculator
extend T::Sig
sig { params(period: Date, employee: Employee, opts: Options).returns(Money) }
def calculate_gross(period, employee, opts)
base = employee.hourly_rate * hours_worked(period, employee)
bonus = opts.include_bonus? ? bonus_for(period, employee) : Money.zero
base + bonus - deductions(period, employee)
end
private
sig { params(period: Date, employee: Employee).returns(Integer) }
def hours_worked(period, employee)
Timesheet.for(employee, period).sum(:hours)
end
end
class Options < T::Struct
const :include_bonus, T::Boolean, default: true
const :round_to_cents, T::Boolean, default: true
end
Er gebeuren drie dingen zodra je dit bestand opslaat.
Ten eerste zal bin/srb tc klagen dat Options niet is gedefinieerd, dat Money niet is gedefinieerd, dat Employee#hourly_rate T.untyped retourneert. Dat is prima — het vertelt je de waarheid over wat het nog niet kan verifiëren. Je voegt óf types toe aan die bestanden ook (geleidelijk mee gaan), óf je vertelt Sorbet dat het je op specifieke waarden moet vertrouwen met T.let en T.cast.
Ten tweede zal de Sorbet-extensie van je editor rode kringeltjes tonen onder elke aanroep van calculate_gross die een string doorgeeft waar een Date wordt verwacht. Hier zie je de waarde: je vindt bugs in code die al jaren stuk staat door de checker voor één methode aan te zetten.
Ten derde, als je ook sorbet-runtime bij boot laadt, zal elke aanroep van calculate_gross bij runtime een TypeError gooien als de argumenten niet met de sig overeenkomen. Dit staat standaard uit tijdens request-time — de runtime-checker is opt-in per bestand — maar ik zet het aan voor development en test, zodat mijn testsuite typeviolations vangt die de statische checker miste door propagatie van T.untyped.
Tapioca: de reden dat Rails Sorbet dragelijk is
Rails leunt zwaar op meta-programming. has_many :posts genereert dynamisch #posts, #posts=, #post_ids, #post_ids=, #build_post en zo’n tiental andere methodes. attr_accessor :name genereert #name en #name=. Sorbet ziet hier niets van zonder hulp. Met de hand signatures schrijven voor elke gegenereerde methode is geen optie.
Tapioca’s dsl-compilers introspecteren je Rails-app bij boot en schrijven RBI-bestanden die beschrijven wat de meta-programming heeft geproduceerd. Voor een model als:
class User < ApplicationRecord
has_many :posts
belongs_to :organization
enum role: { admin: 0, member: 1 }
validates :email, presence: true
end
Genereert Tapioca zoiets als:
# sorbet/rbi/dsl/user.rbi (generated — do not edit)
class User
sig { returns(ActiveRecord::Associations::CollectionProxy[Post]) }
def posts; end
sig { returns(Organization) }
def organization; end
sig { returns(String) }
def email; end
sig { params(value: String).returns(String) }
def email=(value); end
sig { returns(T::Boolean) }
def admin?; end
end
Nu typecheckt User.find(1).posts.where(published: true). User.find(1).emial (typefout) is een statische fout. Je hebt geen enkele annotatie op het model zelf geschreven.
Draai bin/tapioca dsl opnieuw na elke migratie of modelwijziging. Bij mijn klantprojecten hangt dit vast aan bin/setup, bin/rails db:migrate en een pre-push git-hook zodat niemand het vergeet. Er is ook bin/tapioca todo dat RBI-bestanden genereert die methodes beschrijven die Sorbet niet kan resolven maar die wel bestaan bij runtime — een tijdelijke kruk voor de migratie.
Strictness-niveaus: de migratieknop
Elk .rb-bestand heeft een sigil die bepaalt hoe strikt Sorbet het controleert:
# typed: false— Sorbet negeert het bestand volledig behalve syntaxfouten. Dit is de standaard en het veilige startpunt.# typed: true— Sorbet controleert alles wat het kan, maarT.untyped-waarden propageren stilletjes. Dit vangt typefouten, ontbrekende methodes en overduidelijke mismatches in argumenten.# typed: strict— Elke methode moet eensighebben. Elke constante moet een expliciet type hebben.T.untypedis een waarschuwing. Hier vang je de interessante bugs.# typed: strong—T.untypedis nergens toegestaan. Bijna niets in een echte Rails-app compileert op dit niveau; gebruik het voor pure Ruby-libraries.
Het migratiepad voor een legacy Rails-app is: laat alles op false, verhoog eerst plain-old-Ruby-bestanden in app/services/, app/lib/, app/models/concerns/ naar true, en werk daarna naar buiten. Modellen kunnen meestal naar true zodra je Tapioca DSL-RBIs hebt. Controllers zijn het lastigst vanwege params (waarvan Sorbet weet dat het een ActionController::Parameters is, maar niet de vorm van de inhoud) — ik laat controllers het eerste jaar op false staan en vertrouw op strong params + request specs.
De regel die ik teams geef: je mag in een PR de strictness van een bestand niet verlagen. Alleen verhogen. Dit ratelt de dekking over tijd omhoog zonder een groot migratieproject.
srb tc draaien in CI
Het hele punt van statisch typen is fouten vangen vóór merge. Voeg een GitHub Actions-job toe:
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
sorbet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/tapioca dsl --verify
- run: bin/srb tc
bin/tapioca dsl --verify laat de build falen als de gecommitte DSL RBI-bestanden verouderd zijn ten opzichte van de huidige modellen — dit voorkomt “ik ben vergeten te regenereren na het toevoegen van een kolom”-bugs. bin/srb tc draait de typechecker en faalt op elke fout.
Sorbet is snel omdat het in C++ is geschreven en bestanden parallel verwerkt. Op mijn klantproject van 400.000 regels duurt een koude bin/srb tc 8 seconden; op hun Shopify-schaal-vrienden zo’n 30 seconden. Snel genoeg om op elke push te draaien zonder de pipeline te vertragen.
Wat Rails Sorbet niet vangt
Typesystemen vangen een specifieke klasse bugs: verkeerde vorm, verkeerd type, ontbrekende methode, verkeerd gespelde methode. Ze vangen niet:
- Businesslogica-fouten. Als
calculate_grosshet juiste type maar het verkeerde getal retourneert, is Sorbet tevreden. Je hebt nog steeds tests nodig. - N+1 queries. Sorbet weet dat
user.postseen relation retourneert; het weet niet dat je dat binnen een loop aanroept. - SQL-correctheid. Sorbet leest je ActiveRecord-queries niet.
- Race conditions en concurrency-bugs. Types beschrijven geen temporeel gedrag.
- Metaprogramming die Tapioca niet kan zien.
define_methodbinnen een block op basis van runtime-data zal zowel de DSL-compilers als de statische checker verwarren. Je hebt met de hand geschreven RBIs nodig voor de lastigste DSL’s.
Ik zeg dit omdat teams soms geloven dat het adopteren van Rails Sorbet betekent dat ze met minder testdekking kunnen shippen. Dat is niet zo. Het betekent dat hun testsuite niet meer typevormen hoeft te testen en zich kan richten op businesslogica.
Wanneer Rails Sorbet niet gebruiken
Een greenfield Rails 8 side-project van 5.000 regels met één ontwikkelaar heeft geen Sorbet nodig. De overhead van de tooling — RBIs regenereren, editor-setup, de syntaxis aanleren — is reëel, en kleine codebases met één context-drager profiteren onvoldoende. Voeg Sorbet toe wanneer het team op drie ontwikkelaars komt, wanneer de codebase de 30.000 regels overschrijdt, of wanneer je een legacy-app zonder documentatie erft.
Gebruik Sorbet ook niet als je team niet bereid is om bin/tapioca dsl te draaien na modelwijzigingen. Verouderde RBIs zijn erger dan geen types omdat ze liegen. Als de ratel — nooit strictness verlagen, altijd regenereren — niet sociaal of in CI wordt afgedwongen, verrot het geheel binnen een kwartaal.
FAQ
Wat is Rails Sorbet en hoe verschilt het van RBS?
Rails Sorbet is Stripe’s statische typechecker voor Ruby, toegepast op Rails-applicaties. Het gebruikt inline sig { ... }-annotaties in je .rb-bestanden, wordt geleverd met een editor-geïntegreerde language server, en heeft Tapioca om automatisch typestubs te genereren voor gems en Rails-DSL’s. RBS is het officiële Ruby type signature-formaat van matz — het slaat signatures op in aparte .rbs-bestanden en heeft minder tools. Sorbet is in 2026 volwassener en is wat ik aanbeveel voor productie-Rails-apps die vandaag typechecking willen.
Vertraagt sorbet-runtime mijn Rails-app in productie?
Ja, meetbaar. sorbet-runtime omhult elke sig-geannoteerde methode met een typecheck die bij elke aanroep loopt. In een Rails-request die 200 methode-aanroepen afhandelt, zijn dat 200 extra hash-lookups en vergelijkingen. Ik draai sorbet-runtime op het raise-niveau in development en test, en op het log-niveau in production — waar overtredingen naar je error tracker worden gelogd maar niet raisen. Sommige teams schakelen het volledig uit in productie; ook een verdedigbare keuze.
Hoe ga ik met Rails-routes en controllers om met Sorbet?
Routes worden gedekt door Tapioca’s DSL-compiler — bin/tapioca dsl genereert path helpers als user_path(user) met correcte signatures. Controllers zijn lastiger omdat params een ActionController::Parameters retourneert en Sorbet statisch niet kan afleiden welke keys daarin zitten. Mijn patroon is controller-acties op # typed: true te typeren, strong params binnen de actie te gebruiken, en het getypeerde deel te extraheren in een service-object met een echte sig. Probeer params[:user][:email] niet te typeren — dan blijf je eeuwig met het typesysteem vechten.
Kan ik Rails Sorbet op een Rails 5- of Rails 6-applicatie gebruiken?
Ja. Sorbet vereist Ruby 3.1 of nieuwer (de laatste Sorbet-releases lieten oudere Ruby’s vallen), dus je Rails 5/6-app moet op een ondersteunde Ruby draaien. Verder dekken de Tapioca DSL-compilers Rails tot 5.2, en ik heb Sorbet zonder problemen op productie-Rails-6.0-apps geïntroduceerd. Rails 4 wordt niet ondersteund.
Hulp nodig bij het introduceren van Rails Sorbet in een legacy Rails-codebase zonder een kwartaal engineering-runway op annotaties te verbranden? TTB Software is gespecialiseerd in Rails, geleidelijke migraties en het shippen van getypeerde productiecode die daadwerkelijk bugs vangt. We doen dit al negentien jaar.
Related Articles
Rails Systeemtests met Capybara: End-to-End Testing, CI-configuratie en Flaky Tests Elimineren
Rails systeemtests met Capybara: headless Chrome instellen, betrouwbare end-to-end specs schrijven, snel draaien in C...
Rails LLM Structured Output: Betrouwbare JSON van Claude en OpenAI met Schema Validatie
Rails LLM structured output gids: betrouwbare JSON van Claude en OpenAI met schema mode, response validatie en retrie...
Rails Postgres partitioning: miljardenrij-tabellen beheren met pg_partman en native Postgres-partities
Rails Postgres partitioning gids: beheer miljardenrij-tabellen met native Postgres declarative partitioning, pg_partm...