Skip to content
Knowledge Hub

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.

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=30000
const 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, data and info fit anything, so the class keeps attracting work until it reaches 3,000 lines.
  • A repo with both qty and quantity, amt and amount: 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.

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 OrderItem fails with relation "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 explicit name.
  • 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 quotes
SELECT user, action FROM audit_logs;
-- user | action
-- app_rw | REFUND ← the database role name, not the person who acted

Prefer

CREATE TABLE audit_logs (actor_user_id bigint, action text);
SELECT actor_user_id, action FROM audit_logs;

Why

  • user is a keyword; unquoted, PostgreSQL reads it as the user function (a synonym for current_user): no error, every row shows the name of the connected role.
  • order and group fail immediately with syntax 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 exist

Prefer

// 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 exists

Prefer

-- Shorter table, index names following <table>_<column>_idx, under 63 bytes
CREATE 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 NOTICE when 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

  • pg returns bigint (int8) columns as strings because JavaScript has no safe 64-bit integer: '42' !== 42, so even the order’s owner gets a 403.
  • The number in the as cast is a lie the compiler cannot catch. A dedicated UserId type forces explicit conversion everywhere, and also prevents passing an orderId where a userId is expected.

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=42

Prefer

@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 registration
await 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 event

Why

  • SendWelcomeEmail is a command: the producer is ordering a specific consumer around. Adding SMS or CRM means changing the producer.
  • The CustomerRegistered event 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 happened
if (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 deprecated
export 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.

Arguing about casing in every PR

Common review

Reviewer: please drop the I from the interface
Reviewer: this constant must be CONSTANT_CASE
Reviewer: (next PR) an interface with an I again

What to do

eslint.config.js
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.

  1. Does the name answer “what is this / what does it do” without opening the implementation?
  2. Are there empty words (data, info, manager, utils, handle, process)?
  3. Do the verbs (get/find/fetch/list) match the meaning the team agreed on?
  4. Are booleans of the form is/has/can/should and not negated?
  5. Do numbers carry a unit (Ms, Bytes, Cents) in the name or in the type?
  6. Are new tables/columns/constraints snake_case, under 63 bytes, with explicit constraint and index names in the migration?
  7. Do new endpoints, JSON fields, events and topics follow that API’s/system’s convention, and does the change rename a public contract?
  8. Does the name use the business term people actually use?
  1. Why does SELECT * FROM OrderItems fail when the table was created with CREATE TABLE "OrderItems"?
  2. What default name does an unnamed FK from order_items to orders get? Why should you still write the name in the migration?
  3. How do CreateInvoice and InvoiceCreated differ in meaning, and in who has the right to reject them?
  4. How should findOrder and getOrder differ within a team?

Next: go back to the Naming overview or see the Code review cluster.