ActiveRecord's Magic in TypeScript
The thing people love about ActiveRecord isn't the query builder. It's the
feeling. You write User.where({ active: true }).order("created_at") and it
just works. Column names autocomplete, associations chain, and you never write
a migration by hand that doesn't also update the model. The developer experience
is the product.
Porting that to TypeScript means fighting the type system at every turn. Ruby
has method_missing. TypeScript has... none of that. But after months of
working on this, I've landed on a set of patterns that bring most of the magic
across. Some of them are straightforward. Some of them are not.
Virtual Source Files
In Ruby, when you write this.attribute("name", "string") in a model,
method_missing handles the rest: you get a typed getter, a setter, and
query methods, all at runtime. In TypeScript, the type system needs to know
about those members at compile time, or you get nothing. No autocomplete, no
type checking, no red squiggles.
The obvious answer is declare statements. You write the runtime call and then
you write the type declaration:
class Post extends Base {
declare title: string;
declare comments: AssociationProxy<Comment>;
declare author: Author | null;
declare static published: () => Relation<Post>;
static {
this.attribute("title", "string");
this.hasMany("comments");
this.belongsTo("author");
this.scope("published", (rel) => rel.where({ published: true }));
}
}
Every attribute, association, and scope is typed twice: once in the runtime
call, once in the manual declare. It's error-prone, it drifts, and it's
exactly the kind of ceremony ActiveRecord was designed to eliminate.
The fix borrows a trick from Vue and Svelte. Those frameworks have their own file formats and their own compilers, but they still get full IDE support because they present the editor with a virtual version of the file that has the right type declarations. I do the same thing.
A trails-tsc wrapper replaces tsc. It intercepts the compiler's file
reads and injects declare lines based on what the runtime calls say.
You write this:
class Post extends Base {
static {
this.attribute("title", "string");
this.hasMany("comments");
this.belongsTo("author");
this.scope("published", (rel) => rel.where({ published: true }));
}
}
The compiler sees this (injected in memory, no files on disk):
class Post extends Base {
declare title: string;
declare comments: AssociationProxy<Comment>;
declare author: Author | null;
declare static published: () => Relation<Post>;
static {
this.attribute("title", "string");
this.hasMany("comments");
this.belongsTo("author");
this.scope("published", (rel) => rel.where({ published: true }));
}
}
The virtualizer parses the static block, sees this.hasMany("comments"),
infers the target class using Rails' inflection rules
(classify("comments") -> Comment), and splices in the right declare. If
Comment isn't imported, it auto-injects an import type too. If the
inflection is wrong (this.hasMany("commentz")), you get a normal "Cannot
find name 'Commentz'" error at the right line number, because diagnostics are
remapped through the injection offsets.
A companion tsserver plugin does the same transform for the editor, so autocomplete
and go-to-definition work on synthesized members without running a build. The
whole thing is purely syntactic: AST in, text out, no type checker needed. Fast
enough for every keystroke. And if it ever gets it wrong, a manual declare
wins; existing declarations are detected by name and skipped.
Proxies for the Dynamic Parts
Some of ActiveRecord's magic can't be solved at compile time. Named scopes and
association chaining need runtime dispatch. In Ruby, that's method_missing.
In TypeScript, that's Proxy.
The virtualizer handles the types. Proxies handle the behavior. When you write
Post.published(), the type system already knows it returns Relation<Post>
because the virtualizer read the scope definition. But the actual dispatch
happens at runtime: a Proxy on the relation intercepts the property access,
checks the model's scope registry, and routes the call.
The same pattern handles collection associations. blog.posts returns an
AssociationProxy, a CollectionProxy wrapped in a JS Proxy. It supports
chaining (blog.posts.where(...).order(...)) and is also array-like: you can
iterate it, index into it, call .map and .filter. The Proxy intercepts
property access and decides whether to delegate to the loaded array or to the
underlying Relation for query building.
Making CollectionProxy a drop-in for arrays was its own adventure.
Array.find shadows Relation.find (PK lookup), Array.includes shadows
Relation.includes (eager loading), and Array.values() shadows
Relation.values() (query state). Those three are deliberately not delegated;
everything else is.
I use Proxies sparingly. They're powerful but opaque: stack traces get weird,
debuggers get confused, and typeof lies. There are exactly two Proxies in the
codebase: one wrapping CollectionProxy for association access, and one on
Relation for scope delegation.
Lazy Queries and await
In Ruby, queries are lazy by default. User.where({ active: true }) doesn't
hit the database; it returns a relation. The query fires when you iterate,
call .to_a, or access .first. This is core to how ActiveRecord chains work:
you build up a query across multiple method calls and it executes once.
TypeScript doesn't have implicit iteration triggers, but it has await. A
relation implements .then(), .catch(), and .finally(), making it a
thenable. You can await it to execute the query:
// Builds the query, doesn't execute
const query = User.where({ active: true }).order("createdAt");
// Executes when awaited
const users = await query;
The relation's .then() calls .toArray() under the hood: fires the SQL,
resolves with the results. Collection proxies do the same but route through
.load(), which hydrates the proxy's internal target array so subsequent sync
access works.
There's a subtle trap: if .load() returned a thenable, await proxy.load()
would unwrap recursively and return the array instead of the proxy. So
.load() strips its own .then() before returning, so await proxy.load()
gives you back the loaded proxy, not the records. Small detail, but it's the
kind of thing that makes the API feel right.
The Association Problem
Here's where the port gets genuinely hard. In Ruby, post.author is
synchronous. You call it, you get the author. ActiveRecord either loaded it
eagerly (via includes) or loads it lazily on access. Either way, the caller
doesn't care.
In TypeScript, database access is async. If post.author might hit the
database, it has to return a promise, and then every downstream chain of
association access becomes a nested mess of awaits.
I went through three approaches before landing on the one that works.
Option 1: Make everything async. await post.author everywhere. Honest,
but it destroys the ergonomics. Serializers, validations, and callbacks all
go async. The API stops feeling like ActiveRecord.
Option 2: Eager-load everything. Preload all associations, access synchronously. Falls apart with circular associations and conditional loading.
Option 3: Explicit load, synchronous access. This is what shipped. The
sync accessor (post.author) returns the record directly, not a promise.
But it only works after the association has been loaded:
// Via eager loading
const post = await Post.includes("author").find(1);
post.author; // Author object, synchronous
// Via explicit loading
const post = await Post.find(1);
await post.loadBelongsTo("author");
post.author; // Now available synchronously
If you access an unloaded singular association, it throws a
StrictLoadingViolationError, loudly and immediately, with a message naming the
association and pointing at the fix. This matches Rails' strict loading mode,
except it's on by default. The rationale: in Ruby, the silent lazy load is
convenient. In TypeScript, a silent null return for an unloaded association is
a footgun. if (post.author) evaluates as false because nobody preloaded,
not because there's no author.
loadBelongsTo and loadHasOne are the async boundary. They check the cache,
resolve the foreign key, execute the query, and attach the result to the
instance. After that, post.author is synchronous and typed. The cost is one
await or one includes call per association. The benefit is that everything
downstream is sync, chainable, and type-safe.
Collections don't need a separate load call because the AssociationProxy is already
a thenable. await blog.posts loads and returns the proxy. After that,
blog.posts[0], blog.posts.length, and blog.posts.map(...) all work
synchronously.
DX Type Tests
The virtualizer and Proxies give developers the right experience, but only
if I don't break it. The type-level API is complex enough that a refactor can
silently degrade it: a generic gets widened, a method that used to autocomplete
column names starts accepting string. The code still runs. The experience
gets worse. Regular tests don't catch this.
So I write type tests. Tests that check types, not runtime behavior:
import { expectTypeOf } from "vitest";
it("User.find(id) resolves to a single User", async () => {
const u = await User.find(1);
expectTypeOf(u).toEqualTypeOf<User>();
});
it("first() returns User | null, firstBang() returns User", async () => {
expectTypeOf(await User.first()).toEqualTypeOf<User | null>();
expectTypeOf(await User.firstBang()).toEqualTypeOf<User>();
});
it("CollectionProxy carries the element type through", () => {
const proxy = {} as CollectionProxy<Post>;
expectTypeOf(proxy.toArray).returns.resolves.toEqualTypeOf<Post[]>();
});
These run in CI via Vitest's typecheck mode. They don't execute any code; they just ask the compiler "does this type-check the way I expect?" If a refactor widens a generic, the type tests break before any developer notices their autocomplete degraded.
@ts-expect-error is the other workhorse. It asserts that something should
be a type error. If the line below it compiles successfully, the test fails.
This catches the most insidious regression: APIs that silently become more
permissive than they should be.
What Actually Transferred
Not everything survived. class_eval blocks and Ruby's implicit self
scoping don't have TypeScript equivalents. More transferred than I expected,
though: even included and extended callbacks work, via Symbol.for()-keyed
hooks and a Concern abstraction that matches ActiveSupport::Concern.
Migrations ported almost verbatim (same DSL, reversible by default, same
schema_migrations tracking) because they were already imperative with no
method_missing magic. Three database adapters (SQLite, PostgreSQL, MySQL)
translate the Arel-style query AST to each dialect, with multi-database routing
matching Rails' connects_to pattern.
But the DX story is what matters. Virtual source files do what method_missing
did. Proxies handle the runtime dispatch. Type tests make sure the experience
doesn't regress. And await turns out to be a surprisingly natural
lazy-evaluation trigger.
The magic isn't gone. It just has different machinery behind it.
Comments
Leave a Comment