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.
The mistake
Section titled “The mistake”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.
Why I left it alone (temporarily)
Section titled “Why I left it alone (temporarily)”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.
Why UQL still catches the typo
Section titled “Why UQL still catches the typo”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 fieldThe 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.
What does it look like now?
Section titled “What does it look like now?”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; // stringuser.posts.map((post) => post.title); // a list, empty at worst, no guard neededuser.email; // compile error: not selecteduser.profile; // compile error: not populatedA 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.
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.