Skip to content
NewFrom Mongoose to SQL6 min read

ORM Type Safety Comparison

The same ordinary mistakes, written in six ORMs’ own APIs against the same two entities: a misspelled key, a text operator on a number, a sum over a text column, a read of a column you never selected, and eight more. The editor runs the real TypeScript compiler over the real packages. Switch tabs to see what each one catches.

import { asc, eq, gt, like, sum } 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 | createdat -> createdAt
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.where(gt(drizzleUsers.createdat, 0));
// String value against a numeric column | 'one' -> 1
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.where(eq(drizzleUsers.createdAt, 'one'));
// Text operator against a numeric column | like(drizzleUsers.createdAt, 'abc') -> gt(drizzleUsers.createdAt, 1)
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.where(like(drizzleUsers.createdAt, 'abc'));
// Misspelled column in the sort | idd -> id
await db
.select({ id: drizzleUsers.id })
.from(drizzleUsers)
.orderBy(asc(drizzleUsers.idd));
// Sum over a text column | name -> id
await db.select({ total: sum(drizzleUsers.name) }).from(drizzleUsers);
// 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' }]);
// Required column left out of inserted data | 'New User' } -> 'New User', email: 'new@example.com' }
await db.insert(drizzleUsers).values([{ name: 'New User' }]);
// 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 come unchanged from ts-orm-benchmark, which also runs the speed benchmark. Each ORM’s file compiles twice: as written, and with every mistake fixed. A mistake counts as caught only when it errors and the fixed version compiles clean.

The benchmark scores with TypeScript 7.0.2. This site recompiles the same files at build time with TypeScript 6.0.3, the compiler the editor runs too, and the deploy fails if a verdict changes.

Each entry uses the API its main package offers. MikroORM’s entities use defineEntity, since its decorators moved to @mikro-orm/decorators. TypeORM’s decorators are the legacy kind, so its entities use EntitySchema, which scores the same. Drizzle’s flat probes use db.select() and its relation probes db.query.*, the same code its benchmark times.

UQL catches all twelve, Prisma eleven, Drizzle, MikroORM and TypeORM ten, and Sequelize six. A one-probe gap should not decide a tool on its own, and this page says nothing about migrations, error readability or type-checking speed on large schemas.

I wrote UQL, so weigh the verdict accordingly. The method is open, and every entry faces the same probes.


Try it

Terminal window
npm install uql-orm

Entities are plain classes and queries are plain JSON: nothing to generate, and no schema file to keep in sync.