Skip to content
UQL

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.

  • 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

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.

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:

Sequelize
// an index on a column that will not exist
User.init(attributes, { sequelize, indexes: [{ fields: ['emailAddress'] }] });
// an association pointing at a key that will not exist
User.belongsTo(Company, { foreignKey: 'employerId', as: 'employer' });
// queries asking for an attribute and a relation that will not exist
await 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 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:

TypeORM
@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.