Static Types for Dynamic Targets
Ruby's + means addition between integers, concatenation between strings, concatenation between arrays, and a runtime raise between an integer and a string. To produce correct output for one operator in six target languages, a compiler has to know the operand types at each site — in Rust where types are mandatory, and equally in Elixir, Python, and TypeScript where they're not.
That's the bet roundhouse runs on. Two days ago I described the typed-IR substrate and the Red-Green-Refactor rhythm that produces horizontal sweeps across all six targets. Today the compiler gained two pieces of infrastructure that make the bet both testable and extensible.
RBS, on both sides
Two ingestion paths landed:
Ruby + sidecar RBS. The source material for a shared runtime we're growing. runtime/ruby/inflector.rb is 5 lines of Ruby; runtime/ruby/inflector.rbs is 3 lines of type signature. Those eight lines drive byte-equal emission of pluralize into six target-language runtime files. A CI invariant enforces the byte-equality continuously — any regression across any of the six targets fails the build.
Rails app + sidecar RBS. sig/**/*.rbs files in a Rails application root, consumed during ingest. Methods declared there — helpers, concerns, POROs, service classes — flow types into the analyzer the same way Rails conventions do. The escape hatch for code outside the 80% that types by inference alone.
The parsers themselves were cheap to integrate. Ruby's RBS team shipped a standalone C parser in rbs 3.10 specifically so non-Ruby tools could embed it — the design target was Sorbet and JRuby consuming RBS without a CRuby runtime. We added ruby-rbs = "0.3" to Cargo.toml and were walking RBS AST trees the same afternoon. Prism, the Ruby parser roundhouse has been using from day one, was a similarly small integration. Parsing Ruby and parsing Ruby type signatures are both solved problems; the compiler just had to consume both outputs.
Diagnostics, as infrastructure
Each Expr in the typed IR now carries an optional diagnostic annotation. The body-typer sets it during type inference at sites it recognizes as user errors — starting with a + b where Ruby's overloaded operator would raise at runtime (Int + Str, Hash + Hash, and similar).
The annotation rides with the IR. Downstream:
- Each emitter checks it at the top of its expression walker. If set, the emitter produces a target-appropriate raise-equivalent instead of the normal rendering. Python gets
(_ for _ in ()).throw(TypeError(...)), Rust getspanic!(...), Elixir getsraise "...", and so on. The compiled program preserves Ruby's semantics exactly — it raises at runtime at the same line Ruby would. analyze::diagnose(&app)walks the tree and returns every annotation as a structuredDiagnostic. A new CLI binary,roundhouse-check, runs ingest + analyze + diagnose on a Rails app path, prints diagnostics to stderr, and exits non-zero if any fired. Zero diagnostics means the app is in the analyzable subset.
Today's rendering is message-only. The Span infrastructure is still synthetic (no file:line:column yet); that's a separate piece of work that enables both miette-style rendering and TypeScript sourcemaps. The data shape on the IR side is already prepared for both. Production-quality diagnostics arrive when spans do.
The point isn't that today's formatting is impressive. It's that the pipeline is a fixed shape with extensible content. Every new kind of diagnostic follows the same path: one variant added to DiagnosticKind, one hook extended in the body-typer, one branch extended in the message formatter. Consumers — emitters, the CLI, eventually the IDE plugin that doesn't exist yet, eventually miette — read the annotation; they don't have to rediscover what the analyzer already knew.
Static types, even for dynamic targets
The bet the project runs on is that substantial Rails apps can be completely typed — by inference where conventions provide enough, by RBS where they don't.
It's tempting to read that bet as a Rust/Go/Crystal story, where static types are the native language's requirement anyway. It isn't. The bet is equally load-bearing for the dynamic targets — Python, TypeScript, Elixir — because generating correct code for them depends on knowing types just as much.
Consider Ruby's + in three dynamic targets:
- TypeScript.
[1, 2] + [3, 4]in JavaScript doesn't concatenate; it stringifies both arrays and returns"1,23,4". For array concatenation the compiler has to emit[...a, ...b](or.concat), and must know the operands are arrays to pick that form. Without types, it silently emits something that compiles and runs and is wrong. - Elixir. Numeric
+is+. String concatenation is<>. List concatenation is++. These aren't the same operator overloaded — they're three distinct operators. Emitting+when Ruby meant string concat doesn't produce a subtle bug; it raisesArithmeticErrorat runtime. The compiler has to pick between three syntactic forms, and the choice depends on operand type. - Python.
+handles int, float, string, and list natively. Here the native dispatch does match Ruby's — but the compiler still has to refuseInt + Strat emit time with a clear diagnostic, because silent emission of code that raises five minutes into a production deploy is worse than a compile-time error.
All three languages are dynamically typed. None of their type systems forces the compiler's hand. But correct emission in each still depends on the compiler knowing operand types at the site — either inferred or declared.
This generalizes. Every operator Ruby overloads across multiple receiver types — comparison, indexing, arithmetic, coercion — is a site where the target language has distinct syntax per type. Every method Ruby dispatches polymorphically is a site where the target needs the specific receiver type to pick the right call shape. The typed IR is how the compiler picks. Without it, you're stuck with heuristics — pattern-match the source, guess the intent, get it right 80% of the time and silently wrong the rest. Heuristic transpilers land exactly there. A typed IR doesn't.
The second bet rides on the first
The first bet — that Rails apps type cleanly — is the one we can still be wrong about. Each real app the compiler meets is a data point for or against the conjecture. Writebook was a strong early positive; the next mid-size app with concerns will be another; and roundhouse-check turns "does your app type?" from a manual audit into a single command that exits 0 or 1.
The second bet — that typed IR compiles efficiently via standard techniques — is much safer. Static-typed compiled languages outperforming interpreted dynamic ones is among the most-validated claims in computing. JITs narrow this gap significantly — decades of sophisticated engineering has closed a lot of distance — but they rarely close it completely, and the remaining difference is largest on memory footprint and cold-start latency: the two metrics cloud economics are most sensitive to. The compiler techniques needed to bridge it from a typed IR (type-directed dispatch, per-target codegen, specialization, monomorphization) are textbook; nothing novel. If the first bet lands, the second follows almost automatically.
That's a feature, not a limitation. Assembling well-understood pieces against a typed IR is engineering, not research. A team weighing a multi-target Rails compiler under institutional expected-value constraints would still never green-light it, but the engineering risk is low — the risk that remains is empirical: how far does the subset extend?
Escape hatches remain
RBS covers inference-hostile cases today. Pragmas — inline comment conventions, or a dedicated annotation vocabulary — may eventually be needed for things RBS can't express: effect purity for aggressive fold-at-compile-time optimization, ownership hints for Rust emission, memory-layout directives for low-level targets. We haven't needed them yet. Design space is open. Nothing commits until a use case forces the decision.
Where this sits
Six days from first commit. 236 commits. TypeScript and Rust targets matching Rails output byte-for-byte on real-blog. Six target runtimes receiving a pluralize function generated from 5 lines of Ruby. Diagnostic pipeline operational. RBS ingestion on both sides.
No wall yet. Every addition has landed against an existing extension point. Whether that continues is a question that can only be settled by continuing. The instrumentation — CI invariants, the roundhouse-check CLI, the 400+ tests currently green — is the mechanism for noticing the moment it stops continuing.
RubyConf 2026 is in July. I'll have more to say by then.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.