Skip to content
NewComposite primary keys5 min read

ORM Type Safety Comparison

This is what a mistake or typo costs you per ORM. Ten ordinary mistakes - a misspelled key, a text operator on a number, a read of a column you did not select - written in each ORM’s own API against the same two entities.

The editor below runs the real TS compiler over the real packages you would install.

Switch tabs to see what each ORM catches.

import { asc, eq, gt, like } from 'drizzle-orm';
import { drizzleUsers } from '../src/schema';
import { clients } from './clients';
const { drizzleDb: db } = clients;
// Misspelled column in the projection | emial -> email
await db
.select({ id: drizzleUsers.id, email: drizzleUsers.emial })
.from(drizzleUsers);
// Misspelled column in the filter | companyid -> companyId
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.where(gt(drizzleUsers.companyid, 0));
// String value against a numeric column | 'one' -> 1
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.where(eq(drizzleUsers.companyId, 'one'));
// Text operator against a numeric column | like(drizzleUsers.companyId, 'abc') -> gt(drizzleUsers.companyId, 1)
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.where(like(drizzleUsers.companyId, 'abc'));
// Misspelled column in the sort | drizzleUsers.idd -> drizzleUsers.id
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.orderBy(asc(drizzleUsers.idd));
// Misspelled column inside a loaded relation | nmae -> name
await db.query.Company.findMany({
columns: { id: true },
with: { users: { columns: { nmae: true } } },
});
// Misspelled column in inserted data | emails -> email
await db
.insert(drizzleUsers)
.values([{ name: 'New User', emails: 'new@example.com' }]);
// Number written into a text column | 42 -> 'Updated Name'
await db.update(drizzleUsers).set({ name: 42 }).where(eq(drizzleUsers.id, 1));
// Reading a column the projection left out | user.email -> user.name
const [user] = await db
.select({ id: drizzleUsers.id, name: drizzleUsers.name })
.from(drizzleUsers);
export const unselected = user.email;
// Reading a misspelled column off a loaded relation | .nmae -> .name
const [company] = await db.query.Company.findMany({
columns: { id: true },
with: { users: { columns: { id: true, name: true } } },
});
export const nested = company.users[0].nmae;
type-safety/drizzle.ts · static preview

The files above come from ts-orm-benchmark, the repository behind the speed benchmark, and this site changes nothing in them. Each ORM’s ten queries live in a file of its own there and are compiled twice: once as written, once with every mistake corrected. A mistake counts as caught only when it errors and the corrected copy is clean, so nothing scores a point for being broken in a way that has nothing to do with the probe.

That is the only place anything is scored, with TypeScript 7.0.2. This site vendors those six files with the verdicts they produced and compiles them again at build with TypeScript 6.0.3 - this repository’s own compiler, not the 5.9 Monaco vendors inside the editor above - failing to deploy if that run would disagree.

Ten probes cannot separate six ORMs the way a microsecond can. UQL catches all ten and four others catch nine, which is one probe of daylight and not a gap you should choose a tool over; the honest reading is that five of these six check almost everything an ordinary mistake can be, and one does not.

Nothing here covers migrations, tooling, how legible an error is when it does fire, or whether the types stay fast on a schema with two hundred tables. It is one axis, picked because it is the one people argue about without evidence.

Each entry is written in the API its own package offers: @mikro-orm/core exports no entity decorators, since MikroORM 7 moved them to a separate @mikro-orm/decorators package, and TypeORM’s are still the legacy kind, so its entities use EntitySchema - which scores the same either way. Drizzle’s flat probes are written on db.select(), the builder its timed read uses, and its relation probes on db.query.*, the only API it has for one: each half is scored on the same code the benchmark times. MikroORM’s User has no scalar companyId - the company relation owns it - so its filter probes name createdAt instead.

I wrote UQL, and it comes out ahead here by one probe. Take that for what it is worth from its author: the method is in the open, the ten probes are the same for every entry, and the editor above is running the compiler rather than showing you my screenshot of it.