intertwingly

It’s just data

Borrowed from Prisma


Prisma is the typed ORM for TypeScript: you write a schema, it generates a client. For each model in your schema, the generated client gets a typed namespace — prisma.user.findMany() returns User[]; prisma.user.create({ data: ... }) types data against a UserCreateInput specific to the User model; prisma.user.findMany({ where: ... }) types where against UserWhereInput. Each model in a non-trivial schema produces fifteen or twenty specialized types in the generated client. The generated file can run thousands of lines.

Prisma could have shipped a single generic Repository<T> facade. TypeScript supports it. They chose specialization. The reasons they cite — autocomplete clarity, error-message specificity, relationship-aware typing that knows User.posts is Post[] and not just T — are real, but underneath them is a structural distinction worth naming.

Two axes

The set of models in your schema is closed: finite, fully enumerable at codegen time. Once Prisma parses your schema.prisma, every model that will ever exist in the generated client is known. That's one dimension along which Prisma's design varies, and it's the one Prisma specializes — emit one typed surface per model, by name.

The set of fields a caller selects from those models is open: unbounded. New combinations appear forever. select: { name: true, email: true }, include: { posts: true }, every project picks differently. Prisma stays generic here — Prisma.UserGetPayload<T> parameterizes over the projection because there's no closed set to enumerate. The compiler narrows the return type at each call site based on what's selected.

Closed axis specialize. Open axis generic. That's the two-line summary of Prisma's typing strategy.

The structural argument behind it: the closed axis can be enumerated, so specialization gives you the best autocomplete, the cleanest error messages, the most direct mapping from schema to types. The open axis can't be enumerated, so specialization is impossible — generics are the only way to keep typing alive. Prisma's design isn't just a code-generation detail; it's a recognition of which dimensions of variability admit which strategy.

Forced into the same shape

Roundhouse needed exactly this distinction this weekend.

After the cadence post on Friday, Roundhouse's spinel-compile of the real-blog fixture sat at 22 C-level errors — past clang's error cap, into honest counting territory. Twenty of the twenty-two traced to a single root: Hash[Symbol, untyped] value widening to a polymorphic representation flowing through ivars and into per-column setters. Spinel's whole-program inference can't narrow untyped value types; the canonical Rails strong-parameters wrapper class was the choke point. Every attrs[:title] read returned a polymorphic value; every typed setter rejected it.

The wrapper class wasn't structurally wrong for CRuby — Rails has been running this shape for fifteen years. It was structurally wrong for whole-program inference, and structurally wrong for any target — Rust, Crystal, Go — where a polymorphic value can't ride in a struct field without significant ceremony. The Spinel-side fix that's still open (matz/spinel#207) helps at the boundary, but the deeper problem was that Roundhouse hadn't named its closed axis.

The set of resources real-blog declares is closed: Article, Comment. Finite, known at lower-time. The Rails idiom expressed every parameter shape through one generic Parameters class — wrong axis. Specialize the closed axis: emit one ArticleParams { title: String, body: String } per controller resource, one ArticleRow per model from the schema. Typed slots. No untyped. The Spinel and Rust and Crystal compilers all agree at the boundary.

What landed

Three commits over Saturday morning:

200+ unit tests pass. The typing-residual ratchet hits zero. Per-target emit clean. Real-blog spinel-compile drops from 22 to 6 errors — sixteen of the twenty sp_RbVal errors collapse, the rest sit at the from_raw boundary itself (matz/spinel#207) and are a smaller surface than the one they replaced. The two non-sp_RbVal errors are a separate Roundhouse-side issue (controller body chain dispatch).

Why it's the right shape across targets

The thing that surprised me when I worked through it: collapsing back to a generic shape is pure aesthetics for every target Roundhouse ships to.

Rust monomorphizes generics — at compile time, the Rust compiler emits separate concrete code for each instantiation. A hypothetical Params<T> with T = ArticleFields and T = CommentFields produces the same machine code as directly-emitted ArticleParams and CommentParams. Same memory layout, same dispatch, same binary. Crystal monomorphizes the same way. Go can't collapse meaningfully without losing typing (interface{} erases the slot types). TypeScript and Python see only marginal bundle-size differences. Spinel doesn't have generics at all.

The "later additive collapser passes" the specialization strategy keeps available are probably never written. There's no target where collapse pays back enough to write per-emitter collapser code. Specialization is the answer everywhere — for Rust and Crystal because the binary outcome is equivalent, for Go and Spinel because collapse isn't an option, for TS and Python because the cost is bounded and the typing benefit is real.

The decision is overdetermined. Spinel forced the issue; Prisma had already made the call; Rust would have produced equivalent machine code. The constraint surfaced something the architecture should have committed to anyway.

What's left

matz/spinel#207 is the only open inference issue. matz/spinel#214 is a feature request for FFI — substrate work for taking the demo from CRuby-runs-it to spinel-binary-runs-it. The 2-error controller-chain issue is Roundhouse-side, separately tractable.

RubyConf 2026 is July 14-16 in Las Vegas — Matz is keynoting; I'm presenting. If the work is ready by then, it lands in the talk. If not, the cadence — what's been fixed, what's still open — likely gets mentioned anyway. Specialization closed most of the gap to clean compile in one PR. The runtime-substrate piece is the next surface.

What's worth saying about the architecture: the right shape is the right shape regardless of which constraint surfaces it. Prisma found this for typed ORMs. Roundhouse found the same thing for whole-program-typed transpilers. The closed-axis-specialize, open-axis-generic split isn't a Prisma design choice — it's a recognition of which dimensions of variability admit which strategy. Borrowing it works.


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