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.
The mistake
Section titled “The mistake”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.
Why I left it alone, for a while
Section titled “Why I left it alone, for a while”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.
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 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 fieldThe 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.
What does it look like now?
Section titled “What does it look like now?”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; // 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. 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.
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.