Common naming mistakes: from code and database to APIs and events Intermediate
Estimated reading time: 12 min read
This page collects names that cause real bugs, not just names that are “hard to read”. The PostgreSQL behaviors below were run on PostgreSQL 17; the pg (node-postgres) behavior was run with pg 8.23.
TypeScript code
Section titled “TypeScript code”A number without a unit: timeout 30 of what?
Common
// .env: TIMEOUT=30 (the person configuring it meant seconds)const timeout = Number(process.env.TIMEOUT);await fetch(paymentUrl, { signal: AbortSignal.timeout(timeout) });Prefer
// .env: PAYMENT_API_TIMEOUT_MS=30000const paymentApiTimeoutMs = Number(process.env.PAYMENT_API_TIMEOUT_MS);if (!(paymentApiTimeoutMs >= 100 && paymentApiTimeoutMs <= 60_000)) { throw new Error('PAYMENT_API_TIMEOUT_MS must be 100..60000');}await fetch(paymentUrl, { signal: AbortSignal.timeout(paymentApiTimeoutMs) });Why
AbortSignal.timeout()takes milliseconds: every call is aborted after 30 ms, falls into retries, and looks like a “slow provider”.- A unit in the name exposes the mismatch right at the config line; validating the range blocks absurd values at startup.
Bare boolean parameters and negated names
Common
await invoiceService.issue(order, true, false);
function issue(order: Order, notSkipEmail: boolean, noRetry: boolean) { if (!notSkipEmail) return; // send or not send?}Prefer
await invoiceService.issue(order, { shouldEmailCustomer: true, shouldRetry: false });
function issue(order: Order, options: { shouldEmailCustomer: boolean; shouldRetry: boolean }) { if (!options.shouldEmailCustomer) return;}Why
At the call site, true, false says nothing; swapping the two arguments still compiles. A negated name (notSkipEmail) plus ! makes a triple negation: the next person to edit it flips it wrong once and customers stop receiving invoices.
Empty words and home-made abbreviations
Common
class OrderManager { processData(data: any, info: any) { const qty = data.items.length; const cstmrAmt = info.amt * qty; }}Prefer
class SettlementReconciliationService { reconcile(report: SettlementReport, fees: FeeSchedule) { const lineItemCount = report.lineItems.length; const customerFeeCents = fees.perItemCents * lineItemCount; }}Why
Manager,dataandinfofit anything, so the class keeps attracting work until it reaches 3,000 lines.- A repo with both
qtyandquantity,amtandamount: grepping one word misses the other, and refactors are incomplete.
get returns null: callers assume it always exists
Common
async getUser(id: string): Promise<User | null> { return this.repo.findOneBy({ id });}// elsewhere, the caller trusts the word "get"const user = await usersService.getUser(id);await mailer.send(user!.email);Prefer
async findUserById(id: string): Promise<User | null> { return this.repo.findOneBy({ id });}async getUserById(id: string): Promise<User> { const user = await this.findUserById(id); if (!user) throw new NotFoundException(`User ${id} not found`); return user;}Why
The team agreed that get means “must exist”. A get function returning null makes callers add ! to pass the type check, and production blows up with TypeError: Cannot read properties of null instead of a clear 404.
Database
Section titled “Database”The ORM generates quoted camelCase tables and columns
Common
model OrderItem { id BigInt @id @default(autoincrement()) orderId BigInt createdAt DateTime @default(now())}// Actual table: "OrderItem" ("orderId", "createdAt")Prefer
model OrderItem { id BigInt @id @default(autoincrement()) orderId BigInt @map("order_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) @@map("order_items")}Why
- The app works fine. But
SELECT orderId FROM OrderItemfails withrelation "orderitem" does not exist, because unquoted names fold to lowercase. - Every hand-written query, BI tool and ops script must quote every name. TypeORM by default also uses the property name as the column name (
"createdAt"), so you need a naming strategy or an explicitname. - Changing it once data and dashboards exist is very expensive: configure it from the first migration.
A column named user: the query runs, the data is wrong
Common
CREATE TABLE audit_logs ("user" text, action text);
-- A hastily written export script that forgets the quotesSELECT user, action FROM audit_logs;-- user | action-- app_rw | REFUND ← the database role name, not the person who actedPrefer
CREATE TABLE audit_logs (actor_user_id bigint, action text);
SELECT actor_user_id, action FROM audit_logs;Why
useris a keyword; unquoted, PostgreSQL reads it as theuserfunction (a synonym forcurrent_user): no error, every row shows the name of the connected role.orderandgroupfail immediately withsyntax error, which is actually better. A specific column name (actor_user_id) avoids keywords and states the role clearly.
Singular vs plural mismatch between entity and migration
Common
// hand-written migration: CREATE TABLE customers (...)@Entity()export class Customer { @PrimaryGeneratedColumn() id: number;}// Runtime: relation "customer" does not existPrefer
// One convention: plural tables, written explicitly@Entity('customers')export class Customer { @PrimaryGeneratedColumn() id: number;}Why
TypeORM’s DefaultNamingStrategy converts the class name to snake_case for the table name: Customer becomes customer. The error only appears when code touches that entity, usually after the deploy. Mixing singular and plural in one schema also forces everyone to remember each table.
Names over 63 bytes get truncated and collide
Common
CREATE INDEX payment_provider_settlement_reconciliation_line_items_amount_cents_idx ON payment_provider_settlement_reconciliation_line_items (amount_cents);CREATE INDEX payment_provider_settlement_reconciliation_line_items_amount_cents_currency_idx ON payment_provider_settlement_reconciliation_line_items (amount_cents, currency);-- NOTICE: ... will be truncated to "..._line_items_amount_ce"-- ERROR: relation "..._line_items_amount_ce" already existsPrefer
-- Shorter table, index names following <table>_<column>_idx, under 63 bytesCREATE INDEX settlement_lines_amount_cents_idx ON settlement_lines (amount_cents);CREATE INDEX settlement_lines_amount_cents_currency_idx ON settlement_lines (amount_cents, currency);Why
- PostgreSQL only emits a
NOTICEwhen truncating, and migration tools often swallow it. Two names sharing the first 63 bytes become the same name, and the migration fails halfway. - Add a CI check: new identifier names must be ≤ 63 bytes.
A bigint id arrives in Node as a string and is compared with a number
Common
const { rows } = await pool.query('SELECT id, owner_id FROM orders WHERE id = $1', [orderId]);const order = rows[0] as { id: number; owner_id: number };
// currentUser.id is a number (from the JWT)if (order.owner_id !== currentUser.id) throw new ForbiddenException();Prefer
type UserId = string & { readonly __brand: 'UserId' };const toUserId = (raw: string | number): UserId => String(raw) as UserId;
const order = rows[0] as { id: string; owner_id: string };if (toUserId(order.owner_id) !== toUserId(currentUser.id)) { throw new ForbiddenException();}Why
pgreturnsbigint(int8) columns as strings because JavaScript has no safe 64-bit integer:'42' !== 42, so even the order’s owner gets a 403.- The
numberin theascast is a lie the compiler cannot catch. A dedicatedUserIdtype forces explicit conversion everywhere, and also prevents passing anorderIdwhere auserIdis expected.
APIs and events
Section titled “APIs and events”Verbs in the path, and GET used for side effects
Common
@Get('cancelOrder')cancel(@Query('id') id: string) { return this.ordersService.cancel(id);}// Link in an admin email: https://shop.example/api/cancelOrder?id=42Prefer
@Post(':id/cancel')@HttpCode(200)cancel(@Param('id') id: string) { return this.ordersService.cancel(id);}// POST /orders/42/cancel (or POST /orders/42:cancel)Why
RFC 9110 defines GET as safe: spiders, pre-fetchers and link checkers may call it without fear of causing harm. A GET URL that cancels orders will be called by those, cancelling orders nobody clicked.
Path names are nouns; for non-CRUD actions use POST with a sub-resource or :action.
Events named like commands
Common
// Customer service, after a successful registrationawait producer.send({ topic: 'customer.events', messages: [{ key: customer.id, value: JSON.stringify({ type: 'SendWelcomeEmail', customerId: customer.id }) }],});Prefer
await producer.send({ topic: 'customer.customer-registered.v1', messages: [{ key: customer.id, value: JSON.stringify({ customerId: customer.id, registeredAt }) }],});// The email service decides to send the welcome email; SMS and CRM listen to the same eventWhy
SendWelcomeEmailis a command: the producer is ordering a specific consumer around. Adding SMS or CRM means changing the producer.- The
CustomerRegisteredevent is a fact that has happened; a new consumer only needs to subscribe, and the producer does not change.
Events named after tables instead of business actions
Common
publish('OrderUpdated', { orderId, before: oldRow, after: newRow });// Consumer: diff before/after to guess what happenedif (e.before.status !== e.after.status && e.after.status === 9) { /* cancelled? */ }Prefer
publish('OrderCancelled', { orderId, reason, cancelledAt });publish('OrderShippingAddressChanged', { orderId, newAddress, changedAt });Why
Every consumer rewrites the diff logic and has to know that 9 means cancelled. When the order team adds a column or changes a status code, consumers misinterpret it and nobody reports an error.
Renaming a JSON field in a refactor PR
Common PR
// PR: "refactor: clean up naming"export class OrderResponse { order_ID: string; orderId: string;}What to do
// Transition period: return both, mark the old field deprecatedexport class OrderResponse { orderId: string; /** @deprecated use orderId; removed in /v2 */ order_ID: string;}Why
In internal code, the IDE renames safely. With a public contract (JSON, topics, columns other teams read), old clients break the moment you deploy. Treat renaming a contract as a separate migration plan: return both in parallel, track who still uses the old one, then remove it.
Review process
Section titled “Review process”Arguing about casing in every PR
Common review
Reviewer: please drop the I from the interfaceReviewer: this constant must be CONSTANT_CASEReviewer: (next PR) an interface with an I againWhat to do
rules: { '@typescript-eslint/naming-convention': ['error', { selector: 'typeLike', format: ['PascalCase'] }, { selector: 'interface', format: ['PascalCase'], custom: { regex: '^I[A-Z]', match: false } }, { selector: 'enumMember', format: ['UPPER_CASE'] }, ],}Why
Whatever a machine can check (casing, prefixes, length) goes into the linter and CI. Review time is for what machines cannot check: does the name carry the right business meaning.
Code review checklist
Section titled “Code review checklist”- Does the name answer “what is this / what does it do” without opening the implementation?
- Are there empty words (
data,info,manager,utils,handle,process)? - Do the verbs (
get/find/fetch/list) match the meaning the team agreed on? - Are booleans of the form
is/has/can/shouldand not negated? - Do numbers carry a unit (
Ms,Bytes,Cents) in the name or in the type? - Are new tables/columns/constraints
snake_case, under 63 bytes, with explicit constraint and index names in the migration? - Do new endpoints, JSON fields, events and topics follow that API’s/system’s convention, and does the change rename a public contract?
- Does the name use the business term people actually use?
Self-check
Section titled “Self-check”- Why does
SELECT * FROM OrderItemsfail when the table was created withCREATE TABLE "OrderItems"? - What default name does an unnamed FK from
order_itemstoordersget? Why should you still write the name in the migration? - How do
CreateInvoiceandInvoiceCreateddiffer in meaning, and in who has the right to reject them? - How should
findOrderandgetOrderdiffer within a team?
Next: go back to the Naming overview or see the Code review cluster.