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.
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'}`);What each one left behind
Section titled “What each one left behind”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.
How it is measured
Section titled “How it is measured”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.