Skip to content

JavaScript – Best Practices

This page covers the programming practices, documentation standards, and quality rules that distinguish production-grade JavaScript from code that merely runs.

Functions

Keep Functions Small and Focused

Each function should do one thing. If you need to describe it using "and", split it.

// ❌ Function doing multiple unrelated things
async function handleCheckout(userId, cartId) {
  const user = await db.users.findById(userId);
  if (!user.emailVerified) throw new Error('Email not verified');
  const cart = await db.carts.findById(cartId);
  if (cart.items.length === 0) throw new Error('Cart is empty');
  let total = 0;
  for (const item of cart.items) { total += item.price * item.quantity; }
  const order = await db.orders.create({ userId, items: cart.items, total });
  await emailService.send(user.email, 'order-confirmation', { order });
  await db.carts.delete(cartId);
  return order;
}

// ✅ Decomposed — each function independently testable
async function handleCheckout(userId, cartId) {
  const user = await getVerifiedUser(userId);
  const cart = await getNonEmptyCart(cartId);
  const order = await createOrderFromCart(user, cart);
  await notifyUser(user, order);
  await clearCart(cartId);
  return order;
}

Maximum Parameters

Functions should have no more than 3–4 parameters. Group extras into an options object:

// ❌ Too many positional arguments — easy to swap accidentally
function createUser(firstName, lastName, email, role, isActive, locale) { }

// ✅ Options object — named, order doesn't matter, easy to extend
function createUser({ firstName, lastName, email, role, isActive = true, locale = 'en' }) { }

createUser({
  firstName: 'Alice',
  lastName: 'Chen',
  email: 'alice@cygnusdynamics.com',
  role: 'ENGINEER',
});

Pure Functions Where Possible

// ✅ Pure — same input always gives same output, no side effects
function calculateDiscountedTotal(items, discountRate) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return subtotal * (1 - discountRate);
}

Guard Clauses Over Deep Nesting

// ❌ Deep nesting — actual logic buried 3 levels deep
function processRefund(order, user, amount) {
  if (order) {
    if (order.status === 'DELIVERED') {
      if (amount <= order.total) {
        return initiateRefund(order, user, amount);
      }
    }
  }
}

// ✅ Guard clauses — flat, readable, fails fast
function processRefund(order, user, amount) {
  if (!order) throw new Error('Order is required');
  if (order.status !== 'DELIVERED') throw new OrderNotDeliverableError(order.id);
  if (amount > order.total) throw new InvalidRefundAmountError(amount, order.total);
  return initiateRefund(order, user, amount);
}

Equality and Comparisons

Always use === and !==. The only exception is value == null:

// ✅ Strict equality
if (user.role === 'ADMIN') { }
if (order.status !== OrderStatus.CANCELLED) { }

// ✅ The one accepted loose equality — intentional null/undefined check
if (userId == null) {  // catches both null and undefined
  throw new Error('userId is required');
}

// ❌ Loose equality — type coercion
if (count == 0) { }    // true for '', null, false too

Objects and Arrays

Never Mutate Parameters

// ✅ Return new object — original untouched
function applyDiscount(order, discountRate) {
  return {
    ...order,
    total: order.total * (1 - discountRate),
    discountApplied: true,
  };
}

// ❌ Mutating the input parameter
function applyDiscount(order, discountRate) {
  order.total = order.total * (1 - discountRate);  // mutates caller's object
  return order;
}

Use Array Methods Over Manual Loops

// ✅ Array methods — intent is immediately clear
const activeUsers = users.filter(user => user.isActive);
const emailList = activeUsers.map(user => user.email);
const totalRevenue = orders.reduce((sum, order) => sum + order.total, 0);
const hasLargeOrder = orders.some(order => order.total > 10_000);
const adminUser = users.find(user => user.role === 'ADMIN');
const allConfirmed = orders.every(order => order.status === OrderStatus.CONFIRMED);

// ❌ Manual loops — requires reading the whole body to understand intent
const emailList = [];
for (let i = 0; i < users.length; i++) {
  if (users[i].isActive) emailList.push(users[i].email);
}

Object Shorthand

// ✅ Property shorthand
const { id, email } = user;
const userRecord = { id, email };   // not { id: id, email: email }

// ✅ Method shorthand
const service = {
  async getUser(id) { ... },
  formatName() { ... },
};

// ✅ Computed property names
const fieldName = 'status';
const update = { [fieldName]: 'CONFIRMED', updatedAt: new Date() };

Immutable Data Patterns

Object.freeze for Configuration and Enums

Use Object.freeze to prevent accidental mutation of shared configuration objects and enum-like constants:

// ✅ Frozen config — cannot be accidentally mutated
export const DEFAULT_CONFIG = Object.freeze({
  timeout: 5000,
  retries: 3,
  baseUrl: 'https://api.cygnusdynamics.com/v2',
});

// ✅ Frozen enum — prevents typos causing silent bugs
export const OrderStatus = Object.freeze({
  PENDING:   'pending',
  CONFIRMED: 'confirmed',
  CANCELLED: 'cancelled',
});

// ✅ Deep freeze utility for nested objects
function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach(name => {
    const value = obj[name];
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value);
    }
  });
  return Object.freeze(obj);
}

// ❌ Plain object — values can be mutated anywhere in the codebase
const DEFAULT_CONFIG = {
  timeout: 5000,  // anyone can do: DEFAULT_CONFIG.timeout = 0
};

Return New Values, Not Mutated Originals

Treat objects and arrays as immutable — return new instances when updating:

// ✅ Immutable update patterns
const updatedOrder = { ...order, status: 'confirmed', confirmedAt: new Date() };
const updatedItems = [...order.items, newItem];
const filteredItems = order.items.filter(item => item.id !== removedId);

// ✅ For deeply nested updates — update only the changed path
const updatedState = {
  ...state,
  order: {
    ...state.order,
    payment: {
      ...state.order.payment,
      status: 'paid',
    },
  },
};

Performance Patterns

Avoid Creating Functions in Loops

Creating new function instances inside a loop or frequently-called render cycle wastes memory and prevents V8 from optimising the code:

// ❌ New function created on every iteration — inefficient
items.forEach(item => {
  button.addEventListener('click', () => handleItemClick(item.id));
});

// ✅ Create the handler once and use data attributes or closure properly
items.forEach(item => {
  const button = document.createElement('button');
  button.dataset.itemId = item.id;
  button.addEventListener('click', handleItemClick);  // single shared handler
});

function handleItemClick(event) {
  const itemId = event.currentTarget.dataset.itemId;
  processItem(itemId);
}

Map and Set for Frequent Lookups

Use Map and Set for O(1) lookups over large datasets, not Array.find in a loop:

// ❌ O(n) lookup in a loop — slow for large arrays
function getProductPrice(productId, products) {
  return products.find(p => p.id === productId)?.price;
}

// ✅ O(1) lookup with Map — build once, query many times
const productPriceMap = new Map(products.map(p => [p.id, p.price]));
const price = productPriceMap.get(productId);

// ✅ Set for unique value checks
const processedIds = new Set();
for (const order of orders) {
  if (processedIds.has(order.id)) continue;
  processedIds.add(order.id);
  await processOrder(order);
}

WeakMap for Private Object Data

Use WeakMap to associate private data with objects without preventing garbage collection:

// ✅ WeakMap for private instance data (alternative to # private fields)
const privateData = new WeakMap();

class OrderValidator {
  constructor(rules) {
    privateData.set(this, { rules, errorLog: [] });
  }

  validate(order) {
    const { rules, errorLog } = privateData.get(this);
    // ... errors stored privately, not accessible from outside
  }

  getErrors() {
    return [...privateData.get(this).errorLog];
  }
}

// The WeakMap entry is garbage collected when the OrderValidator instance is
// — unlike a regular Map which would hold a strong reference and leak memory

Debounce and Throttle for Frequent Events

// ✅ Debounce — delays execution until activity stops
function debounce(fn, delayMs) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}

// ✅ Throttle — executes at most once per interval
function throttle(fn, intervalMs) {
  let lastCallTime = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastCallTime >= intervalMs) {
      lastCallTime = now;
      fn(...args);
    }
  };
}

// Usage
const debouncedSearch = debounce(handleSearch, 300);
searchInput.addEventListener('input', debouncedSearch);

const throttledScroll = throttle(handleScroll, 100);
window.addEventListener('scroll', throttledScroll);

Error Handling

Always Handle Promise Rejections

// ✅ try/catch at the service layer
async function getOrderById(orderId) {
  try {
    const order = await orderRepository.findById(orderId);
    if (!order) throw new OrderNotFoundError(orderId);
    return order;
  } catch (error) {
    logger.error('Failed to fetch order', { orderId, error: error.message });
    throw error;
  }
}

// ✅ Global handler for unexpected rejections (Node.js)
process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled promise rejection', { reason });
  process.exit(1);
});

// ✅ Global handler in the browser
window.addEventListener('unhandledrejection', event => {
  logger.error('Unhandled promise rejection', { reason: event.reason });
});

Structured Error Logging

// ✅ Structured context — searchable in log aggregators
logger.error('Payment failed', {
  orderId: order.id,
  userId: order.userId,
  amount: order.total,
  currency: order.currency,
  errorCode: error.code,
  message: error.message,
});

// ❌ Unstructured — unparseable
console.log('Error: payment failed for order ' + orderId);
console.error(error);

Comments

Write Comments That Explain Why

// ✅ Explains the business rule — not obvious from the code
// Stripe's idempotency key is limited to 255 chars. We truncate the
// order ID prefix to stay within the limit at high order volumes.
const idempotencyKey = `order_${orderId.substring(0, 200)}_${Date.now()}`;

// ✅ Explains a performance decision
// Map for O(1) lookup — product catalogue has ~50k items, filtering
// the array on every keystroke causes visible jank.
const productMap = new Map(products.map(p => [p.id, p]));

// ❌ Narrates the code
// Set the user's name
user.name = fullName;

// ❌ Commented-out code — delete it; git history preserves it
// const oldFunction = () => { ... };

JSDoc Documentation

All exported functions, classes, and constants must have a JSDoc comment.

Function Documentation

/**
 * Calculates the total cost of an order including applicable taxes and discounts.
 *
 * The tax rate is determined by the customer's billing country. Discounts are
 * applied before tax per pricing policy agreed with Finance (Q1 2025).
 *
 * @param {Order} order - The order to price. Must have at least one item.
 * @param {Customer} customer - The customer placing the order.
 * @returns {number} The final payable amount, rounded to 2dp.
 * @throws {EmptyOrderError} If the order has no items.
 * @throws {UnsupportedCurrencyError} If the order's currency is not supported.
 *
 * @example
 * const total = calculateOrderTotal(order, customer);
 * console.log(total); // 129.99
 */
export function calculateOrderTotal(order, customer) { }

Class Documentation

/**
 * Manages the full lifecycle of customer orders from creation through delivery.
 *
 * @example
 * const service = new OrderService(repository, paymentService);
 * const order = await service.createOrder(request);
 */
export class OrderService {
  /**
   * @param {OrderRepository} repository - Persists and retrieves orders.
   * @param {PaymentService} paymentService - Handles payment processing.
   */
  constructor(repository, paymentService) { }

  /**
   * @param {CreateOrderRequest} request
   * @returns {Promise<Order>}
   * @throws {ValidationError} If the request fails validation.
   * @throws {InsufficientStockError} If any item is out of stock.
   */
  async createOrder(request) { }
}

Type Annotations in JSDoc

/**
 * @param {string} userId
 * @param {{ page?: number, pageSize?: number }} [options]
 * @returns {Promise<Array<Order>>}
 */
async function getOrdersByUser(userId, options = {}) { }

/**
 * @typedef {Object} OrderSummary
 * @property {string} id
 * @property {number} total
 * @property {'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED'} status
 */

Testing Standards

Test File Naming

order-service.js       →  order-service.test.js
user-utils.js          →  user-utils.test.js
payment-processor.js   →  payment-processor.test.js

Test Structure — Arrange / Act / Assert

import { calculateOrderTotal } from './order-utils.js';

describe('calculateOrderTotal', () => {

  it('returns the correct total for a single-item order', () => {
    // Arrange
    const order = { items: [{ price: 49.99, quantity: 2 }], currency: 'USD' };
    const customer = { billingCountry: 'US' };

    // Act
    const total = calculateOrderTotal(order, customer);

    // Assert
    expect(total).toBe(99.98);
  });

  it('throws EmptyOrderError when order has no items', () => {
    // Arrange
    const emptyOrder = { items: [], currency: 'USD' };
    const customer = { billingCountry: 'US' };

    // Act & Assert
    expect(() => calculateOrderTotal(emptyOrder, customer))
      .toThrow(EmptyOrderError);
  });
});
Scope Target
All exported utility functions 100%
Service layer business logic 90%+
API route handlers Integration tests covering all status codes

Security Practices

Never Use eval()

// ❌ Banned — no exceptions
eval(userInput);
new Function(dynamicCode)();
setTimeout('doSomething()', 1000);  // string argument is eval

Validate All External Input

// ✅ Schema validation with Zod
import { z } from 'zod';

const CreateOrderSchema = z.object({
  userId:   z.string().uuid(),
  items:    z.array(z.object({
    productId: z.string().uuid(),
    quantity:  z.number().int().positive().max(100),
  })).min(1),
  currency: z.enum(['USD', 'EUR', 'GBP']),
});

async function createOrder(req, res) {
  const result = CreateOrderSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: result.error.flatten() });
  }
  const validatedData = result.data;
  // proceed safely
}

No Secrets in Code

// ❌ Hardcoded secret
const stripe = new Stripe('sk_live_abc123');

// ✅ Environment variable with startup validation
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error('STRIPE_SECRET_KEY is not set');
const stripe = new Stripe(stripeKey);

Complete Tooling Setup

Every JavaScript project must have these configured and running in CI. Here is the complete setup:

package.json scripts

{
  "scripts": {
    "lint":       "eslint src/ --ext .js --max-warnings 0",
    "lint:fix":   "eslint src/ --ext .js --fix",
    "format":     "prettier --write 'src/**/*.js'",
    "format:check": "prettier --check 'src/**/*.js'",
    "test":       "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "check":      "npm run format:check && npm run lint && npm run test"
  },
  "lint-staged": {
    "*.js": ["eslint --fix", "prettier --write"]
  }
}

.prettierrc

{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "arrowParens": "avoid"
}

.eslintrc.js

module.exports = {
  extends: ['eslint:recommended'],
  parserOptions: {
    ecmaVersion: 2022,
    sourceType: 'module',
  },
  env: {
    es2022: true,
    node:   true,
  },
  rules: {
    'no-var':            'error',
    'prefer-const':      'error',
    'no-unused-vars':    ['error', { argsIgnorePattern: '^_' }],
    'eqeqeq':            ['error', 'always', { null: 'ignore' }],
    'no-eval':           'error',
    'no-implicit-globals': 'error',
    'no-param-reassign': 'error',
    'no-console':        ['warn', { allow: ['warn', 'error'] }],
  },
};

.husky/pre-commit

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

Quick Reference

Practice Rule
Variable declarations const by default, let for reassignment, var banned
Equality Always === and !==; only == null is acceptable
Functions Small, focused, one job; max 3–4 params; verb names
Parameters Use options objects when more than 3–4 params needed
Mutation Never mutate function parameters — return new values
Immutability Object.freeze for enums and config; spread for updates
Performance Map/Set for large lookups; avoid functions in loops; debounce events
Error handling Every await must be covered; typed error classes; structured logging
Comments Explain why, not what; no commented-out code in PRs
JSDoc All exported functions and classes must be documented
Testing Arrange / Act / Assert; 90%+ service coverage
Security No eval(), validate all input, no secrets in code
Tooling Prettier + ESLint + Husky + lint-staged in every project