skip to content

On a large Ruby billing codebase, how would you roll out RBS and Steep incrementally so type checking pays off without stalling feature work?

level: principalimportance: nice to knowfreq 14%

answer

  1. public API and money paths first
  2. one Steepfile target per area
  3. lenient first, strict for new code
  4. RBS_TEST_TARGET validates signatures
  5. steep stats as the progress metric

basics

~20 s

Start with the public API and money-handling paths, seed signatures with rbs prototype and TypeProf, check them with lenient Steepfile targets that tighten over time, validate signatures against the test suite with RBS_TEST_TARGET, and track progress with steep stats.

solid answer

~50 s

I would treat it as a ratchet, not a migration. First pick the code where a type error is expensive, the billing and money paths and the library's public API, and write their RBS by hand, seeded by `rbs prototype rb` and TypeProf. In the Steepfile each area becomes a **target**: new code under `D::Ruby.strict`, legacy code under `D::Ruby.lenient` with `ignore` for directories not yet started, and CI running `steep check --severity-level=error` so only real errors block merges. `rbs collection install` in CI supplies gem signatures from a committed lock file. To keep signatures honest, the test suite runs once with `RBS_TEST_TARGET='Billing::*'` and `rbs/test/setup`. Progress is measured with `steep stats`, the share of typed calls, and a rule that every new or touched file gets signatures. I would stop short of 100%: dynamic corners stay `untyped` rather than being forced.

code

ruby · 15 lines
ruby
# Steepfile
D = Steep::Diagnostic

target :core do
  signature "sig/core"
  check "lib/billing/payments", "lib/billing/money"
  configure_code_diagnostics(D::Ruby.strict)
end

target :legacy do
  signature "sig/legacy"
  check "lib/billing"
  ignore "lib/billing/payments", "lib/billing/money", "lib/billing/reports"
  configure_code_diagnostics(D::Ruby.lenient)
end

go deeper

for a junior

Recall that gradual typing can be adopted file by file and that Steep checks only what its Steepfile targets include.

for a middle

Explain the tools in the plan: prototype and TypeProf for drafts, Steepfile targets with presets, and rbs collection for gems.

for a senior

Show the operational safeguards: severity-level gating, runtime validation with RBS_TEST_TARGET, and watching steep:ignore and untyped counts.

for a principal

Own the trade-offs: where types pay back, how strictness ratchets, RBS versus Sorbet, and the ongoing cost of keeping signatures true in every pull request.

## Framing the decision Gradual typing in Ruby is **opt-in per method, per file and per directory**, which is its strength: you do not have to convert a large codebase to get value. The risk is the opposite failure, a half-finished effort where signatures exist, nobody trusts them, and the checker is silenced until it proves nothing. A rollout plan exists to avoid both a stalled big-bang migration and slow decay. ## Where to start Put signatures where a wrong type costs the most and where the boundary is stable: - **Money paths**: amounts in integer cents versus floats, currency symbols, nil receipts from a payment gateway. - **The public API** of the billing library that other teams call; if it is a gem, ship `sig/` so callers can type-check against it. - **New code**, which is cheapest to type while it is being written. Leave highly dynamic areas for last, or for never: code built on `method_missing`, `define_method` generators or heavy `send` gains little and costs a lot. ## The Steep configuration as a dial | Area | Steepfile setup | CI policy | |---|---|---| | new modules | `target :core` with `D::Ruby.strict` | errors block merges | | typed legacy | `target :legacy` with `D::Ruby.lenient` | errors block, warnings reported | | not started | listed with `ignore` | not checked | | tests | separate target, `lenient` | informational | `steep check --severity-level=error` keeps the gate on real errors while warnings stay visible. Moving a directory from `ignore` to `lenient` to `strict` is a small, reviewable change, which is exactly the granularity a ratchet needs. `# steep:ignore` with a diagnostic name covers individual lines, and a count of those comments is worth watching. ## Keeping signatures honest A checker only proves the code agrees with the signatures; if the signatures are wrong, the proof is worthless. Three habits protect against that: 1. **Runtime validation in CI**: one test run with `RBS_TEST_TARGET='Billing::*' bundle exec ruby -r rbs/test/setup ...` prepends checks onto those classes and reports `ArgumentTypeError` or `ReturnTypeError` when real calls disagree with the RBS. 2. **Generated drafts are reviewed**: `rbs prototype rb` and TypeProf speed up the first pass, but their `untyped` holes and over-wide unions need a human. 3. **`rbs -I sig validate` and `rbs collection install` in CI**, with `rbs_collection.lock.yaml` committed, so every build sees the same signatures. ## Measuring progress - `steep stats` reports typed versus untyped calls per file, a better metric than "files with a .rbs". - Track the number of `steep:ignore` comments and `untyped` occurrences; both should fall. - Watch escaped defects in the typed area: nil errors and wrong-argument bugs are what the effort is meant to remove. ## Trade-offs to own - **Separate files versus inline**: `.rbs` files are stable but drift from the code; inline `# @rbs` keeps types beside it but was still experimental as of RBS 4.2 and Steep 2.1, so adopting it is a bet on its future. - **RBS with Steep versus Sorbet**: Sorbet adds runtime enforcement through `sorbet-runtime` and a mature per-file `# typed:` scheme, at the cost of a runtime dependency and a Ruby DSL in every class; RBS has no runtime footprint and is what Ruby itself ships. Mixing both in one codebase doubles the maintenance. - **Cost**: every signature is code to maintain. If a team will not update signatures in the same pull request as the code, the effort should stay small and focused rather than expand. ## What I would present to the team A short written policy: which directories are typed and at what strictness, that touched files gain signatures, how CI gates, and a quarterly review of the stats. That turns typing from a side project into part of normal feature work.

  • The team starts adding `untyped` and `steep:ignore` to make CI pass. How do you respond?
    Treat both as debt with a budget, not as fixes. Report their counts next to `steep stats` in CI, require a reason in the ignore comment's review, and prefer moving a troubled directory back to a lenient target over sprinkling ignores. If the same pattern keeps forcing `untyped`, it is a sign that area is too dynamic to be worth typing.
  • When would you choose Sorbet instead of RBS and Steep for this rollout?
    When runtime enforcement matters more than zero overhead: `sorbet-runtime` checks real calls against each `sig`, which protects boundaries the static checker cannot see. The price is a runtime dependency and Ruby DSL in every class. If the codebase already uses Sorbet, adding RBS alongside it would double the signature maintenance.

saying these in an interview costs you the question

  • Type the whole codebase in one migration before enabling the checker in CI.
  • Start with the most dynamic, metaprogrammed code because it has the most bugs.
  • Generated TypeProf signatures can be committed without review.
  • Passing steep check proves the signatures are correct.
  • The goal is 100% typed calls regardless of cost.