intertwingly

It’s just data

Live Types for Rails


Two earlier posts in this series were about what whole-program type inference buys a compiler. Static Types for Dynamic Targets made the case that you cannot correctly transpile Ruby's + — addition, or concatenation, or a runtime raise, depending on the operands — to other languages without knowing the operand types at every site. The Ruby JRuby Was Built to Run made the case that the same inference is what lets you partially evaluate Rails into standalone code that runs an order of magnitude faster. In both, the types were the enabler. The transpilation and the performance were the payoff.

This post is about a third payoff, and it isn't downstream of the other two. It's the types themselves.

To emit a typed target, Roundhouse has to resolve the things Rails resolves at runtime: which class an association returns, which columns a model has and what they deserialize to, which instance variables a controller hands a template, whether a find_by came back nil. It builds that picture statically, from an unmodified Rails app, with no annotations. Having built it, it can do something other than emit code with it. It can answer questions about it.

A type checker that needs neither annotations nor a running app

Here is the whole pitch in one exchange. This is the real lobste.rs codebase — not a fixture — being asked, through Roundhouse's MCP server, whether an expression can be nil:

> can_be_nil  app/models/story.rb:216  (self.domain)
  Yes — type is `Domain?`, which admits nil.

> can_be_nil  app/models/story.rb:734  (t = Tag.active.find_by(...))
  Yes — type is `Tag?`, which admits nil.

> type_at     app/models/story.rb:324  (similar_stories.first)
  untyped | Story | nil  (may be nil) — Send node

Nothing about that codebase was annotated. Nothing was booted. No database was touched. The answers are correct: story.domain is belongs_to :domain, optional: true, so it is genuinely nilable; find_by returns nil on a miss; .first on an empty relation is nil. Roundhouse distinguishes find_by (nilable) from find (raises, so not), and optional: true from the Rails-5-default required association — all by inference, none by signature.

I want to be precise about why that's notable, because "Ruby type tooling" is a crowded enough phrase that the claim can sound smaller than it is. There are four ways a tool can know something about your Rails code, and they trade off along two axes: whether it needs to run the app, and whether it goes past names to actual types.

names / navigation / values inferred types + nil-safety
static (no boot) ruby-lsp Sorbet / Steep — but require annotations · Roundhouse: no annotations
runtime (boots app) ruby-lsp-rails, Tidewave

The bottom-right cell is empty on principle: you can't prove a type by running the app, only observe the value you happened to get this time. That leaves the top row. Sorbet and Steep reach the deep column, but only if you annotate; its annotation-free corner is where Roundhouse sits, alone. It's worth walking the other three, because they're good tools and the honest pitch is that Roundhouse complements them — it does not replace any of them.

What the incumbents do, and where they stop

ruby-lsp (Shopify) is static and fast and is the right baseline for editor features. But its type knowledge is deliberately minimal — its own type inferrer describes itself as resolving only what can be known "without requiring a type system or annotations," and in practice that means literals, self, constants, and a few name-based guesses. It has no method-return types, no local-variable types, and no nil-safety; its index resolves names, and its hover shows you a declaration, not the inferred type of an arbitrary expression. Ask it the type of x in x = foo.bar.baz and it has no answer to give. That's not a flaw — it's a scope decision, and it's why ruby-lsp defers to Sorbet when Sorbet is present.

ruby-lsp-rails fills in the Rails knowledge ruby-lsp lacks — and it does it by booting your application. It spawns bundle exec rails runner in the background and reflects against the live app: Model.columns for schema, reflect_on_association for associations, Rails.application.routes for routing. The information is real because the app is real. But it is reflection in service of navigation: when it indexes has_many :posts it records the method exists with no type attached; it'll jump you to the associated class, but it won't tell you that user.posts is a relation of Post, and it has no notion of nil-safety beyond a column's null flag. And it needs an app that boots, a database it can reach, and a server it keeps warm — which means it goes quiet exactly when your app is mid-refactor and won't load.

Tidewave (from José Valim and Dashbit) is the most powerful of the three and the furthest from Roundhouse. It mounts an MCP server inside your running dev app and gives an agent project_eval, execute_sql_query, get_logs — a REPL and a live database connection. It is ground truth: it can run your actual code, see your actual rows, and resolve metaprogramming that exists only at runtime, because it asks the runtime. By its own framing it fills "missing gaps, such as evaluating code inside your Rails app" — the things that "simply do not exist in LSP." But its knowledge is value-based: you learn what an expression is by evaluating it, in this instance, right now. There are no static types and no nil-safety, the app has to boot, and project_eval and execute_sql_query are not read-only — they run real code with real side effects against your dev database.

Put the three together and the shape of the gap is clear. ruby-lsp is static but shallow. ruby-lsp-rails and Tidewave both need a running app — ruby-lsp-rails stops at navigation, and Tidewave's depth is a value observed, not a type proven. Sorbet and Steep are static and deep, but you pay for it in annotations. Roundhouse is the static, deep, annotation-free corner: it gives you the type of any expression and whether it can be nil, with no boot, no database, no side effects, and on code too broken to run — for the subset of Rails it understands.

That qualifier is the whole game, and the next section is about being honest about it.

The honest edges

It covers a subset. Roundhouse understands the Rails it can compile, and that is not all of Rails yet. Where it understands your code, it gives you types nobody else can; where it doesn't, it should tell you so rather than guess. Which brings up a bug I found while writing this post.

Coverage has to be visible, or it's worse than useless. A type checker that silently skips a file and then reports "no problems" is lying. Roundhouse ingests .html.erb and jbuilder templates; it does not yet ingest HAML. On a HAML-heavy app like Mastodon — 87% of its views — that's a lot of UI the analyzer can't see, and until this week it skipped those files without a word. That's now fixed: an un-ingested template surfaces as an explicit gap, so the tool tells you "310 views I didn't analyze" instead of implying they're clean. The same change made the server degrade gracefully past any single construct it doesn't model — previously one exotic node anywhere (an alias, a class << self) could turn every query on a real app into "ingest failed." Both were the kind of gap you only find by pointing the thing at code it wasn't tuned for, which is exactly why I did.

For the record, HAML is a gap every tool on that table shares — ruby-lsp has no HAML support, the runtime tools don't parse views at all. The difference is only whether the gap is announced. Roundhouse lowering HAML to its IR would make it the one tool that types HAML views; that's roadmap, not done.

The warnings are the interesting part. Point Roundhouse at its demo blog and it reports zero errors and five warnings. All five are on the jbuilder JSON views, where json.array! and json.extract! build a response dynamically. Roundhouse types the builder as untyped and flags every call on it — not because it failed, but because that's the one place in the app where a value's shape is intentionally open. And that shape is the API's response contract. The warnings aren't noise to suppress; they're the analyzer pointing at exactly the seam where, someday, an inferred response type could be published as a typed client SDK. A diagnostic ledger that's honest about what it hasn't modeled is the same ledger that tells you what's worth modeling next.

Fast enough to leave running

"Live" is a performance claim, so here are the numbers. Every query — every hover, every agent turn — re-ingests and re-analyzes the whole application from scratch: no caching, no incremental update, no parallelism. This is the naïve thing, the entire program every time, on one laptop, release build, warm cache.

app Ruby LOC views analyzed per query
the demo blog 550 12 3 ms
writebook 2,400 65 18 ms
lobste.rs 8,500 80 150 ms
Mastodon 75,000 0 200 ms
showcase 27,000 351 500 ms

Line count barely predicts the cost, and the reason is instructive. The work is type inference, not parsing, so it scales with how hard the code is to reason about, not how much of it there is. Showcase is the slowest in the table at a third of Mastodon's size because its controllers are inference-dense — it raises an order of magnitude more diagnostics to chase. And Mastodon's number is flattered: 87% of its views are HAML, which Roundhouse doesn't ingest yet, so it analyzes zero of them — while showcase pays about 175 of its 500 milliseconds to type its 351 ERB and jbuilder templates, at roughly half a millisecond each. That's a real discount to carry against Mastodon's 200 ms: when HAML lands, that number climbs by something on the order of a hundred milliseconds, which narrows this gap without closing it — strip the views from both and showcase's Ruby alone still out-costs Mastodon's. The LSP and the MCP pay nearly the same bill; the editor adds about ten percent for its buffer overlay and the protocol round trip.

The claim the title rests on is the left column of that table meeting the right: a few milliseconds to a half second, nothing cached, is fast enough to run on every keystroke and every agent turn with no warm server to babysit — which is why there isn't one. That won't hold forever — but the first response to the half-second case probably isn't incremental reanalysis. Roundhouse has had no performance work at all; it was built to be correct, not fast, and the whole-program pass above has never met a profiler. That showcase runs slower than an application three times its size, for a reason I haven't yet chased down, is exactly the sort of result that says there's ordinary optimization to be found before the architecture has to change. Profiling the analyzer is the cheap lever and comes first; incremental parsing is the heavier one, held for whenever a truly large app demands it. The surprising part is how much headroom the naïve approach already has — the largest real application I can point at answers in a fifth of a second with nothing cached, nothing parallel, and not one optimization pass behind it.

Trying it

None of this is packaged for end users yet — there's no marketplace extension, no gem install. What exists is a development path. The VS Code dev client builds the language server (cargo build --release --bin roundhouse-lsp) and attaches it to Ruby files under the F5 loop, giving you inferred-type hovers, inlay hints, nil-safety underlines, and find-references over a whole Rails app. The server speaks standard LSP, so the same binary drives any LSP editor — Neovim, Helix, Zed, Emacs. The MCP server (roundhouse-mcp) is the same analysis behind a Model Context Protocol skin — the transcript above is literally its output — so a coding agent gets the static-type feedback loop that compiled languages give for free and Rails never has.

The ideal agent loop, in fact, runs both halves of that table: static first, because it's cheap and safe and side-effect-free and works when the app won't boot; then runtime, when you need ground truth on live data or genuinely dynamic metaprogramming. Roundhouse and Tidewave aren't competitors; they're the two ends of that loop.

Where this sits

The query layer is real today; the read-only LSP and the MCP server are real today. What's next is mostly the same work that's always next for this project: extending the subset. The current proving ground is lobste.rsthe Rails benchmark the YJIT team built — where the error count is down to sixteen, all of them in one recursively-rendered view, and a follow-on effort will try emitting the result as CRuby to put it on the benchmark. The stretch target after that is Mastodon: seventy-five thousand lines of application code that already ingests without a panic in under a second, with the HAML views now honestly accounted for rather than silently skipped.

The thing I keep coming back to is that none of this required a new engine. The same inference that emits nine targets answers the editor's and the agent's questions — it has to do that work either way. Pointing it at a cursor instead of a code generator is just attaching a different consumer to answers it already computes, fast enough that the consumer can ask on every keystroke. The types were the point all along. It turns out you can also just ask them — live.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.