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” becomes a count instead of a word on a homepage. UQL caught nine. This is about the tenth, and why I put off fixing it.

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

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

I knew how to fix it. I didn’t want to, and not because of 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 didn’t want to trade a daily convenience for a compile error on a mistake I rarely make.

So I built it, upgraded every project I maintain, work 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 TypeScript changed. 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 holding them, so the compiler answers 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 are still checked. $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 knows it:

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. Where a helper has to take a projected row, name the shape instead of widening the query:

import type { QueryFindResult } from 'uql-orm';
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 and Bun. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.