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