NodeJSCode Style & Conventions

Code Style & Conventions

Code style isn't about aesthetics — it's about reducing cognitive load. When naming, structure, and patterns are consistent across a codebase, developers read unfamiliar code faster, spot anomalies more readily, and make fewer "this looked right stylistically but was wrong semantically" mistakes. Enforcing style automatically (Prettier, ESLint) removes the conversation from code review entirely. This page covers the naming and structural conventions that most professional Node codebases share, plus the async and error-handling patterns that define readable modern Node code.

Naming conventions

TS
// Variables, functions, methods, parameters: camelCase
const userCount = 0
function findUserById(id: string) {}

// Classes, types, interfaces, enums: PascalCase
class UsersService {}
interface UserDto { name: string }
type CreateUserResult = { id: string }
enum UserRole { Admin = 'admin', Member = 'member' }

// Constants / env-sourced values: UPPER_SNAKE_CASE
const MAX_RETRY_COUNT = 3
const { DATABASE_URL } = process.env    // env vars are UPPER_SNAKE already

// Files: kebab-case matching the primary export
// users.service.ts  → exports UsersService
// auth.middleware.ts → exports authenticate middleware
// create-user.dto.ts → exports CreateUserDto
camelCase for values, PascalCase for types, UPPER_SNAKE for constants — and file names match their primary export in kebab-case
These conventions are close to universal in the Node/TypeScript ecosystem. **camelCase** for variables, functions, and object properties mirrors JavaScript's native style. **PascalCase** for classes, interfaces, types, and enums distinguishes "things you instantiate or use as a type" from "things you call or assign." **UPPER_SNAKE_CASE** for module-level constants (and environment variable names) signals "this value doesn't change" and makes it visible at a glance. **Kebab-case filenames** that match the primary export (`users.service.ts` exports `UsersService`) create a predictable map from file to content — you can predict the import path from the class name and vice versa. Consistency here is the goal; the specific convention matters less than everyone using the same one.
Async code conventions

TS
// ✅ async/await — readable, debuggable, easy to follow the flow:
async function getUser(id: string): Promise<User> {
  const user = await usersRepo.findById(id)
  if (!user) throw new NotFoundError(id)
  return user
}

// ✅ Parallel work — don't await sequentially when independent:
const [user, orders] = await Promise.all([
  usersRepo.findById(id),
  ordersRepo.findByUser(id),
])

// ❌ Callback hell — avoid in new code; use promisified APIs or util.promisify:
fs.readFile(path, (err, data) => {
  if (err) return callback(err)
  // nested callback...
})
Use `async/await` for all async code — callbacks only when forced; parallelize with `Promise.all` for independent work
Modern Node code is almost entirely **`async/await`** — it reads like synchronous code, stack traces are meaningful (unlike raw Promise chains), and `try/catch` handles errors naturally. The remaining convention is **`Promise.all`** for parallel work: sequential `await` for independent operations is a performance bug disguised as readable code — two database calls that don't depend on each other should run in parallel. The rule: `await` sequentially only when the second depends on the first; otherwise `Promise.all`. Callbacks appear only when calling truly callback-only APIs — wrap them with `util.promisify` (or the `fs/promises` API, `dns.promises`, etc.) to keep the rest of the code in the async/await world. Never mix callback and async styles in the same module.
Error handling conventions

TS
// 1. Use typed domain error classes — never strings or generic Error:
class NotFoundError extends Error {
  readonly statusCode = 404
  constructor(resource: string) { super(`${resource} not found`); this.name = 'NotFoundError' }
}

// 2. Throw at the point of detection, catch at the boundary:
async function deleteUser(id: string) {
  const user = await repo.findById(id)
  if (!user) throw new NotFoundError(`User:${id}`)  // throw here...
  await repo.delete(id)
}

// 3. Map to HTTP at the boundary — one central error middleware:
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof NotFoundError) return res.status(404).json({ error: err.message })
  logger.error(err)
  res.status(500).json({ error: 'Internal server error' })
})
Throw typed domain errors at detection; catch at the boundary; map to HTTP in one central middleware — never scatter status codes
Consistent error handling is one of the highest-leverage style decisions. Use **typed error classes** with a `statusCode` field rather than generic `Error` or raw strings — so code can `instanceof`-check the type and handle it differently. **Throw at the point of detection** (the service throws `NotFoundError`, not the caller) and **catch at the boundary** (the HTTP layer or a test). Map errors to HTTP responses in **one central error middleware** rather than in every handler — scattering `res.status(404)` across fifty route handlers means changing the response format requires touching all of them. The central middleware reads the error type and emits the right status/body, so the rest of the app is HTTP-agnostic.
Module and import conventions
  • Prefer named exports over default exports — refactor tools can rename them reliably, and the name is explicit at the import site.

  • Use path aliases (@config, @services) rather than deep relative imports (../../..) — readable and refactor-safe.

  • Import order: Node built-ins first, then npm packages, then internal modules — with a blank line between groups (ESLint import plugin enforces this).

  • Keep imports at the top of the file — no dynamic require() in the middle of functions except for lazy-loading.

  • Avoid circular imports — they cause subtle initialization bugs and signal a missing abstraction layer.

Named exports, path aliases, consistent import order, and no circular dependencies are the import conventions worth enforcing
Import conventions affect daily readability and refactoring safety. **Named exports** (`export class UsersService`) are preferable to default exports (`export default UsersService`) because rename-refactor tools track the exported name, and the import site is explicit — `import { UsersService }` rather than `import Whatever`. **Path aliases** configured in `tsconfig.json` (`@services/users.service`) are shorter, stable under directory restructuring, and much more readable than `../../../../services`. **Circular dependencies** (A imports B, B imports A) cause initialization timing bugs that are notoriously hard to diagnose — if you find one, it usually reveals a missing shared abstraction that both A and B should depend on instead. Enforce import ordering and no-circular rules with ESLint (`eslint-plugin-import`).
Next
Pull it all together into a complete checklist: [Node.js Best Practices](/nodejs/best-practices).