What a rename breaks: six TypeScript ORMs
You pick a TypeScript ORM also for the type safety, right? Then press F2, the editor renames what it can see (automatically), and the rest of the schema quietly keeps the old name (unsynced).
One model, 19 places that name three of its members: indexes, both sides of a relation, a check constraint, a projection, raw SQL in a filter.
I pressed F2 on emailAddress to email, employerId to workplaceId, employer to workplace in each and
counted what got left behind.
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'}`);Two ways to miss
Section titled “Two ways to miss”A name the rename missed fails in one of two ways (and they’re not the same problem).
Some stop compiling. That costs an afternoon: tsc hands you the list, you work through it, nothing reaches
production.
The rest compile. The build is green, the tests that don’t touch that query pass, and the failure turns up later as a column that isn’t there.
| ORM | Followed the rename | Stopped by the compiler | Left behind silently |
|---|---|---|---|
| UQL | 19 | 0 | 0 |
| Drizzle | 14 | 4 | 0 |
| TypeORM | 12 | 0 | 5 |
| Prisma | 4 | 7 | 3 |
| Sequelize | 4 | 0 | 12 |
| MikroORM | 3 | 13 | 3 |
Rows don’t all add up to 19: where an ORM can’t express a probe, there’s nothing to rename and nothing to count.
Don’t read the first column on its own
Section titled “Don’t read the first column on its own”MikroORM follows the fewest of the six, and it’s still the second safest.
Its defineEntity builder types index properties against the entity, so the stale name in
{ properties: ['emailAddress'] } is a compile error. 13 of its misses stop the
build, 3 get through. Go by the leftmost column and you’d worry about the wrong
tool. I did, for about a day.
Sequelize is the one to worry about. Its classes are properly typed and InferAttributes does real work on
the model, but everything around the model takes strings, and nothing checks them:
// an index on a column that will not existUser.init(attributes, { sequelize, indexes: [{ fields: ['emailAddress'] }] });
// an association pointing at a key that will not existUser.belongsTo(Company, { foreignKey: 'employerId', as: 'employer' });
// queries asking for an attribute and a relation that will not existawait User.findAll({ attributes: ['id', 'emailAddress'] });await User.findAll({ include: ['employer'] });All four compile after the rename. So do 12 of the 19 probes, and nothing warns you about any of them.
TypeORM: decorators fine, strings not
Section titled “TypeORM: decorators fine, strings not”TypeORM is the interesting one, because the decorators aren’t the problem. @Index((user: User) => [user.employerId]) takes a callback, so F2 edits it and a stale name is a type error. That part works.
The SQL inside those same decorators doesn’t:
@Index((user: User) => [user.employerId], { where: `"emailAddress" <> ''` })@Check(`"emailAddress" <> ''`)@Column({ generatedType: 'STORED', asExpression: 'lower("emailAddress")' })The callback half renames, the string half doesn’t, and there’s no seam between them. Three of TypeORM’s 5 silent misses are that pattern; the other two are an inverse relation and a query.
That’s what the whole table comes down to: whether the name goes through something the compiler resolves.
mappedBy((user) => user.employer) is a property read. as: 'employer' is a string that looks like one.
Drizzle sits high because its schema is values its queries import, so a stale name either follows or fails
to compile. UQL follows all 19 because it never took a string option anywhere, indexes, checks
and raw SQL included.
The files, the renames and every verdict are
ts-orm-benchmark’s, on each ORM’s current
stable release and its most type-safe entity API. Every rename is TypeScript 7.0.2’s
own, or prisma-language-server 31.12.10’s for schema.prisma. I wrote UQL, so weigh that last
row accordingly and then go check it: the rename page runs the real compiler in your
browser instead of showing you my screenshot of it. Press F2 yourself.
UQL is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.