Skip to content

In search of the type-safest ORM

The type-safety benchmark writes ten ordinary mistakes in six ORMs’ own APIs and compiles them, so “type-safe” stops being a word on a homepage and becomes a count. UQL caught nine. This is the story of the tenth, and of why I refused to fix it for a while.

const [user] = await pool.findMany(User, {
$select: { id: true, name: true },
});
user.email; // undefined at runtime. Every time.

The email column was never selected, so the property is not there. Nothing objected, because the result was typed as the whole entity no matter what the query asked for. The undefined travels into a response body or the next update, and surfaces days later as a null column nobody wrote.

I knew how to fix it. At first I didn’t want to, and the reason wasn’t the type machinery.

Narrowing takes something away. Once a row is { id, name } instead of User, it stops being the loose bag application code likes to pass around: hand it to a helper typed (user: User) => ... and it’s rejected. I wasn’t keen to trade a daily convenience for a compile error on a mistake I rarely make.

So I built it, upgraded every project I maintain, the professional ones included, and counted the damage. There was none: Variability alone has 200+ find calls across 25+ files, and it compiled clean, unchanged.

The pattern I was protecting turned out to be one I don’t actually write. Code that tops up a row starts from a full entity, because it needs the fields; code that projects ships the row straight out, to an API response, a list, a picker.


To shape the row, the query has to be captured as a type parameter, and that is where the ground moved. TypeScript 6.0 stopped reporting unknown keys on a literal checked against a captured map. Prisma’s and Drizzle’s projections are exactly that shape, which is why a misspelled column compiles for both today, and why they sit at nine and eight rather than ten.

UQL captures the field names instead of the map that holds them, so the compiler is asked a different question:

$select: { id: true, emial: true }
// against a captured map: is 'emial' a known key? - no longer reported
// against captured names: 'id' | 'emial' extends FieldKey<User>? - 'emial' is not a field

The second is a constraint, and constraints never went anywhere. $where, $sort and each populated relation aren’t captured at all, so they keep the checks they always had.


The row is what you asked for, and the compiler is aware:

const [user] = await pool.findMany(User, {
$select: { name: true },
$populate: { posts: { $select: { id: true, title: true } } },
});
user.name; // string
user.posts.map((post) => post.title); // a list, empty at worst, no guard needed
user.email; // compile error: not selected
user.profile; // compile error: not populated

A query with no projection still returns the whole entity, so nothing changes where you didn’t ask for anything. And where a helper genuinely has to take a projected row, name the shape rather than widening the query:

type UserCard = QueryFindResult<User, 'id' | 'name'>;

That is ten of ten on the benchmark, the only entry with no red mark.

Each ORM's ten mistakes, underlined by the compiler as the tab changes. Type it yourself.

Ten probes can’t separate six ORMs the way a microsecond can, and I chose the ten. So the claim is the narrow one: these are ten mistakes people make on an ordinary afternoon, and this is the ORM that refuses all of them. Every probe is in type-safety/, compiled as written and again with each mistake corrected. Clone it and check.

And if you’re where I was, weighing a check you suspect you won’t need against a convenience you’re sure you use: upgrade a branch and count the damage. It’s a cheaper argument than the one I had with myself.



UQL is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.