Skip to content
NewThe SQL tag: a function called without parentheses4 min read

What survives a rename? Six TypeScript ORMs

Press F2 on a field and your editor renames every use it can link to the declaration. With an ORM, that is only the places where the schema is linked to your code.

Each tab is one ORM’s model and 19 places that name three of its members, renamed the way F2 does: emailAddress to email, employerId to workplaceId, employer to workplace. Green is what the rename edited, red what it missed and the compiler stops, amber what it missed and still compiles.

  • renamed
  • stopped by the compiler
  • left behind silently
import { count, eq, relations, type SQL, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import {
check,
index,
integer,
pgTable,
serial,
text,
uniqueIndex,
} from 'drizzle-orm/pg-core';
export const companies = pgTable('Company', { id: serial().primaryKey() });
export const users = pgTable(
'User',
{
id: serial().primaryKey(),
emailAddress: text().notNull(),
employerId: integer().references(() => companies.id),
// Generated column
emailLower: text().generatedAlwaysAs(
(): SQL => sql`lower(${users.emailAddress})`,
),
},
(t) => [
// Index on the field
index().on(t.emailAddress),
// Composite unique index
uniqueIndex().on(t.emailAddress, t.employerId),
// Covering index column | n/a: Drizzle's index builder has no INCLUDE
// Expression index
index().on(sql`lower(${t.emailAddress})`),
// Partial index condition
index()
.on(t.employerId)
.where(sql`${t.emailAddress} <> ''`),
// Check constraint
check('address_present', sql`${t.emailAddress} <> ''`),
],
);
export const usersRelations = relations(users, ({ one }) => ({
// Foreign key of a relation
employer: one(companies, {
fields: [users.employerId],
references: [companies.id],
}),
}));
export const companiesRelations = relations(companies, ({ many }) => ({
// Inverse side of a relation
staff: many(users),
}));
const schema = { companies, users, usersRelations, companiesRelations };
declare const db: NodePgDatabase<typeof schema>;
// Field in the projection
await db.select({ id: users.id, address: users.emailAddress }).from(users);
// Field in the filter
await db.select().from(users).where(eq(users.emailAddress, 'ada@example.com'));
// Field in the sort
await db.select().from(users).orderBy(users.emailAddress);
// Field inside a loaded relation
await db.query.companies.findMany({
with: { staff: { columns: { emailAddress: true } } },
});
// Relation loaded by name
await db.query.users.findMany({ with: { employer: true } });
// Field in inserted data
await db.insert(users).values({ emailAddress: 'ada@example.com' });
// Field in updated data
await db
.update(users)
.set({ emailAddress: 'ada@example.com' })
.where(eq(users.id, 1));
// Foreign key in a grouped count
await db
.select({ company: users.employerId, total: count() })
.from(users)
.groupBy(users.employerId);
// Field read off the result
const [user] = await db.select().from(users);
export const address = user.emailAddress;
// Raw SQL in a filter
await db
.select()
.from(users)
.where(sql`lower(${users.emailAddress}) = ${'ada@example.com'}`);
rename-safety/drizzle.ts · static preview

The last column is what reaches production: a stale name that compiles, then fails at run time or in a migration. Rows can add to less than 19 where an ORM cannot express a probe.

ORM Followed the rename Stopped by the compiler Left behind silently
Sequelize 4 0 12
TypeORM 12 0 5
Prisma 4 7 3
MikroORM 3 13 3
Drizzle 14 4 0
UQL 19 0 0

Sequelize types the model with InferAttributes, but indexes, associations and queries take unchecked strings: fields: ['emailAddress'], as: 'employer', attributes, include.

TypeORM’s @Index((user: User) => [user.employerId]) follows the rename. Three of its 5 silent misses are SQL strings in the same decorators: a partial index’s where, a @Check body, and a generated column’s asExpression.

Prisma renames its schema and regenerates the client, so most misses become compile errors. Raw SQL in $queryRaw is among the 3 that slip through.

MikroORM follows the fewest renames, but its defineEntity builder types index properties, so 13 misses stop the build. The 3 silent ones are a joinColumn('employerId') string, a generated-column template and one query.

Drizzle’s schema is values its queries import, so nothing slips through silently. The 4 compile errors are writes and loaded relations checked against the table without a link to it.

UQL follows all 19. Indexes, checks and relations name members through callbacks, and every query key is typed off the entity.

The files, renames and marks are ts-orm-benchmark’s. Each rename is TypeScript 7.0.2’s own, or prisma-language-server 31.12.15’s for schema.prisma, replayed in your browser and checked live. The build fails if F2 here would edit any other place, or if a red line would move.

Prisma’s tab renames schema.prisma, regenerates the client, and compiles the queries against it. TypeORM’s legacy decorators compile in a project and a worker of their own.

I wrote UQL, so weigh this accordingly. The benchmark is open, and every tab runs the same compiler.