Skip to content
UQL

What survives a rename? Six TypeScript ORMs

A rename is a refactor nobody thinks twice about because it is often pretty simple: just press F2, enter the new name and trust that every place is updated. An ORM’s rename reaches only as far as it links its schema to the code.

Each tab below is one ORM’s model and 19 places that name three of its members, renamed the way F2 renames: emailAddress to email, employerId to workplaceId, employer to workplace. Green is what the rename edited, red what it left behind for the compiler to stop, and amber what it left behind that 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

UQL follows all 19: its indexes, checks and relations name members through callbacks, and every query key is typed off the entity. Drizzle leaves none behind silently, but 4 for the compiler to stop, where its writes and loaded relations are checked against the table without being linked to it. The other four leave some behind with no error at all: 12 in Sequelize, whose indexes, associations and query options name members as plain strings, 5 in TypeORM, 3 in MikroORM and 3 in Prisma, each breaking only when the code runs or the schema migrates.

Every file, rename and mark is ts-orm-benchmark’s. Each rename is the one the ORM’s own language server made there, TypeScript 7.0.2’s or, for Prisma’s schema, prisma-language-server 31.12.10’s, replayed here in your browser, where the compiler this site builds with answers it live. The build fails if F2 in this editor would edit any other place, or if a red line would move.

Prisma renames its schema and never touches its queries, so its tab renames schema.prisma, regenerates the client, and shows the queries compiled against it, as the benchmark does. TypeORM’s decorators are the legacy kind, so the benchmark compiles its file in a project of its own and the editor checks it in a worker of its own.

I wrote UQL, so take this for what it is worth from its author: the benchmark is in the open, and every tab above runs the same compiler.