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.
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 projectionawait db.select({ id: users.id, address: users.emailAddress }).from(users);
// Field in the filterawait db.select().from(users).where(eq(users.emailAddress, 'ada@example.com'));
// Field in the sortawait db.select().from(users).orderBy(users.emailAddress);
// Field inside a loaded relationawait db.query.companies.findMany({ with: { staff: { columns: { emailAddress: true } } },});
// Relation loaded by nameawait db.query.users.findMany({ with: { employer: true } });
// Field in inserted dataawait db.insert(users).values({ emailAddress: 'ada@example.com' });
// Field in updated dataawait db .update(users) .set({ emailAddress: 'ada@example.com' }) .where(eq(users.id, 1));
// Foreign key in a grouped countawait db .select({ company: users.employerId, total: count() }) .from(users) .groupBy(users.employerId);
// Field read off the resultconst [user] = await db.select().from(users);export const address = user.emailAddress;
// Raw SQL in a filterawait db .select() .from(users) .where(sql`lower(${users.emailAddress}) = ${'ada@example.com'}`);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.
Where this comes from
Section titled “Where this comes from”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.