How to Write Good Comments in JavaScript: When, Why, and How
Comments are one of the most misunderstood tools in programming. Beginners tend to comment too much, explaining what every line does. Experienced developers sometimes comment too little, assuming everyone understands their clever code. The truth lies in between: good comments explain why something exists, not what it does. The code itself should be clear enough to show the what.
This guide teaches you when comments add value and when they are noise, how to make your code self-documenting so it needs fewer comments, how to use JSDoc to create professional function documentation, and how to use special comment tags that help you and your team track work in progress.
When to Comment and When Not to Comment
The single most important principle of commenting is this: comments should explain why, not what. If your code needs a comment to explain what it does, the code itself probably needs to be rewritten.
Comments That Add Value
Explain the reasoning behind a non-obvious decision:
// We use a 300ms debounce instead of 100ms because
// the autocomplete API rate-limits at 10 requests/second
const DEBOUNCE_DELAY = 300;
// Users in Japan see dates as YYYY/MM/DD by default.
// We override the locale only when the user hasn't explicitly set a preference.
if (!user.dateFormatPreference) {
formatter.setLocale(user.region);
}
Explain a workaround or non-obvious constraint:
// Safari doesn't support the `gap` property in flexbox before v14.1.
// Using margin on children as a fallback.
.flex-container > * + * {
margin-left: 8px;
}
// The API returns amounts in cents, not dollars.
// We divide by 100 here to display user-friendly prices.
const displayPrice = apiResponse.amount / 100;
Clarify complex algorithms or regular expressions:
// Luhn algorithm: validates credit card numbers by doubling every second
// digit from right, summing all digits, and checking if total % 10 === 0
function isValidCardNumber(number) {
// ...implementation
}
// Match email addresses: local-part@domain.tld
// Allows: letters, numbers, dots, hyphens, underscores in local part
// Requires: at least one dot in domain, 2-63 char TLD
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/;
Document edge cases and unexpected behavior:
// parseInt('08') returns 8 in modern browsers, but returned 0 in older
// ones that treated leading zeros as octal. We pass radix 10 explicitly.
const month = parseInt(input, 10);
// Array.sort() sorts in-place AND returns the same array reference.
// We spread into a new array to avoid mutating the original.
const sorted = [...items].sort((a, b) => a.price - b.price);
Warn about consequences:
// WARNING: Changing this key invalidates all existing user sessions.
// Coordinate with the ops team before modifying.
const SESSION_SECRET = process.env.SESSION_SECRET;
// This function deletes data permanently. There is no undo.
// Called only from the admin panel after double confirmation.
function purgeUserData(userId) {
// ...
}
Comments That Are Noise
Stating the obvious:
// BAD: restating what the code already says
let age = 25; // set age to 25
age = age + 1; // increment age by 1
const users = []; // create empty array
return result; // return the result
// The code is perfectly clear without these comments.
// They add visual clutter and maintenance burden.
Paraphrasing the function or variable name:
// BAD: the name already says this
// Get user by ID
function getUserById(id) { }
// Calculate total price
function calculateTotalPrice(items) { }
// Check if email is valid
function isValidEmail(email) { }
// GOOD: only comment if there's something non-obvious
// Returns null (not undefined) when user isn't found,
// to distinguish "not found" from "not yet loaded"
function getUserById(id) { }
Closing brace comments:
// BAD: if you need these, your function is too long
if (user.isActive) {
if (user.hasPermission) {
for (let i = 0; i < items.length; i++) {
// ...50 lines of code...
} // end for
} // end if hasPermission
} // end if isActive
// GOOD: refactor into smaller functions instead
function processActiveUserItems(user, items) {
if (!user.isActive || !user.hasPermission) return;
items.forEach(processItem);
}
Commented-out code:
// BAD: dead code clutters the file. Use version control instead.
function processOrder(order) {
// const oldTotal = order.items.reduce((sum, item) => sum + item.price, 0);
// const discount = oldTotal > 100 ? 0.1 : 0;
// const total = oldTotal * (1 - discount);
const total = calculateTotal(order.items);
// applyLoyaltyPoints(order.customer, total);
// sendConfirmationEmail(order.customer.email, order);
return total;
}
// GOOD: delete the code. Git remembers everything.
function processOrder(order) {
const total = calculateTotal(order.items);
return total;
}
If you are tempted to comment out code "just in case," remember that version control (Git) preserves every version of every file. You can always go back. Commented-out code confuses future readers who do not know if it was disabled temporarily, permanently, or accidentally.
The Decision Rule
Before writing a comment, ask yourself:
Can I make the code clearer instead of adding a comment?
├── Yes → Rename variables, extract functions, simplify logic
└── No → Is the comment explaining WHY, not WHAT?
├── Yes → Write the comment
└── No → The comment is probably unnecessary
Self-Documenting Code vs. Comments
The best comment is the one you do not need to write. Self-documenting code uses clear naming, simple structure, and small functions to communicate its intent without comments.
Technique 1: Descriptive Variable Names
// BAD: needs a comment to explain
const d = new Date();
const t = d.getTime() - s.getTime();
if (t > 86400000) { /* ... */ }
// GOOD: the code explains itself
const currentDate = new Date();
const timeSinceStart = currentDate.getTime() - startDate.getTime();
const ONE_DAY_IN_MS = 86400000;
if (timeSinceStart > ONE_DAY_IN_MS) { /* ... */ }
The magic number 86400000 is meaningless without context. Naming it ONE_DAY_IN_MS makes the comparison immediately understandable.
Technique 2: Extract Complex Conditions into Named Variables
// BAD: what does this condition mean?
if (user.age >= 18 && user.hasId && !user.isBanned && user.country === 'US') {
allowPurchase();
}
// GOOD: the variable name IS the comment
const isEligibleForPurchase =
user.age >= 18
&& user.hasId
&& !user.isBanned
&& user.country === 'US';
if (isEligibleForPurchase) {
allowPurchase();
}
Technique 3: Extract Logic into Well-Named Functions
// BAD: complex inline logic with a comment explaining it
// Check if the string is a valid hex color
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(input)) {
applyColor(input);
}
// GOOD: the function name IS the documentation
function isValidHexColor(color) {
return /^#([0-9A-Fa-f]{3}){1,2}$/.test(color);
}
if (isValidHexColor(input)) {
applyColor(input);
}
// BAD: a block of logic with a comment header
function processOrder(order) {
// Calculate discount based on loyalty tier
let discount = 0;
if (order.customer.tier === 'gold') {
discount = 0.15;
} else if (order.customer.tier === 'silver') {
discount = 0.10;
} else if (order.customer.totalOrders > 10) {
discount = 0.05;
}
// Apply discount and tax
const subtotal = order.total * (1 - discount);
const tax = subtotal * 0.2;
const finalTotal = subtotal + tax;
return finalTotal;
}
// GOOD: functions replace comment headers
function processOrder(order) {
const discount = getCustomerDiscount(order.customer);
const subtotal = applyDiscount(order.total, discount);
const finalTotal = addTax(subtotal, 0.2);
return finalTotal;
}
function getCustomerDiscount(customer) {
if (customer.tier === 'gold') return 0.15;
if (customer.tier === 'silver') return 0.10;
if (customer.totalOrders > 10) return 0.05;
return 0;
}
function applyDiscount(amount, discountRate) {
return amount * (1 - discountRate);
}
function addTax(amount, taxRate) {
return amount * (1 + taxRate);
}
The refactored version has no comments but is easier to understand. Each function does one thing, and its name describes that thing.
Technique 4: Use Enums or Constants Instead of Magic Values
// BAD: what do these numbers mean?
if (user.role === 1) {
showAdminPanel();
} else if (user.role === 2) {
showEditorPanel();
} else if (user.role === 3) {
showViewerPanel();
}
// GOOD: constants are self-documenting
const ROLES = {
ADMIN: 1,
EDITOR: 2,
VIEWER: 3,
};
if (user.role === ROLES.ADMIN) {
showAdminPanel();
} else if (user.role === ROLES.EDITOR) {
showEditorPanel();
} else if (user.role === ROLES.VIEWER) {
showViewerPanel();
}
Technique 5: Simplify Boolean Returns
// BAD: verbose, needs a comment to feel justified
// Returns true if the user is an active admin
function isActiveAdmin(user) {
if (user.isActive && user.role === 'admin') {
return true;
} else {
return false;
}
}
// GOOD: the logic is the return statement
function isActiveAdmin(user) {
return user.isActive && user.role === 'admin';
}
When Self-Documenting Code Is Not Enough
Self-documenting code explains what the code does. Comments are still needed when:
- Why a specific approach was chosen over alternatives
- Why a seemingly incorrect or unusual pattern is intentional
- What external constraints or business rules drive the logic
- What assumptions the code makes about its inputs
- How a complex algorithm works at a high level
// Self-documenting: WHAT it does is clear from the names
function retryWithExponentialBackoff(fn, maxRetries) {
// WHY comment: still valuable, explains the design decision
// We use exponential backoff instead of fixed intervals because
// the payment API throttles clients that retry too aggressively.
// The base of 2 was recommended by the API documentation.
for (let attempt = 0; attempt < maxRetries; attempt++) {
const delay = Math.pow(2, attempt) * 1000;
// ...
}
}
JSDoc: Documenting Functions, Parameters, and Return Types
JSDoc is a standardized comment format for documenting JavaScript code. It uses special tags inside /** */ comments that editors and tools can parse to provide auto-completion, type checking, and documentation generation.
Basic Function Documentation
/**
* Calculates the total price including tax.
*
* @param {number} price - The base price of the item.
* @param {number} taxRate - The tax rate as a decimal (e.g., 0.2 for 20%).
* @returns {number} The total price including tax.
*/
function calculateTotal(price, taxRate) {
return price * (1 + taxRate);
}
When you hover over calculateTotal in VS Code, you see the description, parameter types, and return type. Auto-completion also shows parameter names and types as you type.
Common JSDoc Tags
@param: Document parameters
/**
* Creates a new user account.
*
* @param {string} name - The user's display name.
* @param {string} email - The user's email address.
* @param {Object} [options] - Optional configuration.
* @param {string} [options.role='viewer'] - The user's role.
* @param {boolean} [options.sendWelcomeEmail=true] - Whether to send a welcome email.
* @returns {Object} The created user object.
*/
function createUser(name, email, options = {}) {
const { role = 'viewer', sendWelcomeEmail = true } = options;
// ...
}
Parameter syntax breakdown:
| Syntax | Meaning |
|---|---|
@param {string} name | Required parameter of type string |
@param {string} [name] | Optional parameter |
@param {string} [name='default'] | Optional with default value |
@param {string|number} id | Parameter accepts string or number |
@param {Object} options | Parameter is an object |
@param {string} options.role | Property of an object parameter |
@returns: Document return value
/**
* Finds a user by their ID.
*
* @param {number} id - The user's unique identifier.
* @returns {Object|null} The user object, or null if not found.
*/
function findUserById(id) {
return users.find((user) => user.id === id) || null;
}
@throws: Document exceptions
/**
* Divides two numbers.
*
* @param {number} a - The dividend.
* @param {number} b - The divisor.
* @returns {number} The result of a / b.
* @throws {Error} If the divisor is zero.
*/
function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
@example: Provide usage examples
/**
* Formats a number as a currency string.
*
* @param {number} amount - The amount to format.
* @param {string} [currency='USD'] - The ISO 4217 currency code.
* @returns {string} The formatted currency string.
*
* @example
* formatCurrency(1234.5);
* // Returns: "$1,234.50"
*
* @example
* formatCurrency(1234.5, 'EUR');
* // Returns: "€1,234.50"
*/
function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
Documenting Complex Types
@typedef: Define custom types
/**
* @typedef {Object} User
* @property {number} id - The unique identifier.
* @property {string} name - The display name.
* @property {string} email - The email address.
* @property {('admin'|'editor'|'viewer')} role - The user's role.
* @property {boolean} isActive - Whether the account is active.
* @property {Date} createdAt - When the account was created.
*/
/**
* Fetches all active users from the database.
*
* @returns {Promise<User[]>} A promise that resolves to an array of users.
*/
async function getActiveUsers() {
const response = await fetch('/api/users?active=true');
return response.json();
}
@callback: Document callback functions
/**
* @callback FilterPredicate
* @param {*} item - The current item being evaluated.
* @param {number} index - The index of the current item.
* @returns {boolean} True to include the item, false to exclude.
*/
/**
* Filters an array and returns a summary.
*
* @param {Array} items - The array to filter.
* @param {FilterPredicate} predicate - The filter function.
* @returns {{ filtered: Array, removedCount: number }}
*/
function filterWithSummary(items, predicate) {
const filtered = items.filter(predicate);
return {
filtered,
removedCount: items.length - filtered.length,
};
}
Documenting Classes
/**
* Represents a shopping cart.
*
* @class
*/
class ShoppingCart {
/**
* Creates a new ShoppingCart instance.
*
* @param {string} customerId - The ID of the customer.
*/
constructor(customerId) {
/** @type {string} */
this.customerId = customerId;
/** @type {Array<{id: number, name: string, price: number, quantity: number}>} */
this.items = [];
}
/**
* Adds an item to the cart. If the item already exists,
* increments its quantity.
*
* @param {Object} product - The product to add.
* @param {number} product.id - The product ID.
* @param {string} product.name - The product name.
* @param {number} product.price - The unit price.
* @param {number} [quantity=1] - The quantity to add.
* @returns {void}
*/
addItem(product, quantity = 1) {
const existing = this.items.find((item) => item.id === product.id);
if (existing) {
existing.quantity += quantity;
} else {
this.items.push({ ...product, quantity });
}
}
/**
* Calculates the total price of all items in the cart.
*
* @returns {number} The total price.
*/
getTotal() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
}
JSDoc for Arrow Functions and Variables
/** @type {number} */
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
/** @type {Map<string, User>} */
const userCache = new Map();
/**
* Doubles a number.
*
* @param {number} n - The number to double.
* @returns {number} The doubled value.
*/
const double = (n) => n * 2;
/**
* @type {(a: number, b: number) => number}
*/
const add = (a, b) => a + b;
How Much JSDoc Is Appropriate?
Not every function needs JSDoc. Apply it strategically:
// NO JSDOC NEEDED: name and types are obvious
function isEven(n) {
return n % 2 === 0;
}
const double = (n) => n * 2;
// JSDOC VALUABLE: public API, complex parameters, non-obvious behavior
/**
* Parses a date string in multiple formats and returns a Date object.
* Supported formats: ISO 8601, RFC 2822, US (MM/DD/YYYY), EU (DD/MM/YYYY).
*
* @param {string} dateString - The date string to parse.
* @param {string} [format='auto'] - The expected format, or 'auto' to detect.
* @returns {Date|null} A Date object, or null if parsing fails.
*/
function parseDate(dateString, format = 'auto') {
// ...
}
General rule:
- Always JSDoc: Public APIs, library functions, complex utility functions, functions with non-obvious parameters or return values
- Optional JSDoc: Private helper functions, simple getters/setters, one-liner utilities
- Skip JSDoc: Trivially obvious functions, inline callbacks
TODO, FIXME, and HACK Comments
Special comment tags mark work that needs attention. They are searchable, trackable, and many editors highlight them differently.
TODO: Planned Improvements
// TODO: Add pagination support for large result sets
function getUsers() {
return fetch('/api/users').then((r) => r.json());
}
// TODO(alice): Implement retry logic before Q2 release
async function sendNotification(userId, message) {
await fetch('/api/notify', {
method: 'POST',
body: JSON.stringify({ userId, message }),
});
}
// TODO: Replace with proper i18n library
function getGreeting(language) {
if (language === 'es') return 'Hola';
if (language === 'fr') return 'Bonjour';
return 'Hello';
}
FIXME: Known Bugs or Broken Functionality
// FIXME: This calculation is wrong for leap years
function getDaysInYear(year) {
return 365;
}
// FIXME: Race condition when two users update the same record simultaneously
async function updateProfile(userId, data) {
const user = await getUser(userId);
Object.assign(user, data);
await saveUser(user);
}
HACK: Temporary Workarounds
// HACK: The API returns dates as strings without timezone info.
// We append 'Z' to force UTC interpretation. Remove when API v3 ships.
function parseApiDate(dateStr) {
return new Date(dateStr + 'Z');
}
// HACK: Force layout recalculation to fix animation glitch in Safari.
// See: https://bugs.webkit.org/show_bug.cgi?id=XXXXX
element.style.display = 'none';
element.offsetHeight; // Force reflow
element.style.display = '';
Other Tags
// NOTE: This function is called from both the web app and the mobile API
function formatResponse(data) { }
// OPTIMIZE: This O(n²) approach works for small datasets (<1000 items)
// but will need a more efficient algorithm for the enterprise tier
function findDuplicates(items) { }
// DEPRECATED: Use formatCurrency() instead. Will be removed in v3.0.
function formatMoney(amount) { }
// REVIEW: Is this the correct business logic for international orders?
function calculateShipping(order) { }
Finding Tags in Your Codebase
VS Code: Install the Todo Tree extension. It scans your project and displays all TODO, FIXME, HACK, and other tags in a sidebar panel.
Command line:
# Find all TODOs in your project
grep -rn "TODO\|FIXME\|HACK" src/
# With context
grep -rn -A 1 "TODO\|FIXME\|HACK" src/
ESLint: The no-warning-comments rule can flag TODO and FIXME comments:
{
"rules": {
"no-warning-comments": ["warn", {
"terms": ["todo", "fixme", "hack"],
"location": "start"
}]
}
}
This produces warnings so TODOs are visible but do not block development.
Best Practices for Tag Comments
// BAD: vague, no context
// TODO: fix this
// FIXME: doesn't work
// HACK: temporary
// GOOD: specific, actionable, traceable
// TODO(team-payments): Add support for cryptocurrency payments (JIRA-1234)
// FIXME: Returns incorrect total when cart contains items with 0% tax (BUG-567)
// HACK: Workaround for Chrome bug #123456. Remove after Chrome 120 stable.
Include:
- Who is responsible (name or team)
- What specifically needs to be done
- Why it is needed or what is broken
- When it should be addressed (ticket number, version, deadline)
Generating Documentation from JSDoc
JSDoc comments are not just for editor hints. They can be used to generate complete HTML documentation for your project.
Using the JSDoc Tool
# Install JSDoc
npm install jsdoc --save-dev
# Generate documentation
npx jsdoc src/ -d docs/
This reads all /** */ comments in your src/ folder and generates an HTML documentation website in the docs/ folder.
JSDoc Configuration
Create a jsdoc.config.json file:
{
"source": {
"include": ["src/"],
"includePattern": ".+\\.js$",
"excludePattern": "(node_modules|test)"
},
"opts": {
"destination": "./docs",
"recurse": true,
"readme": "./README.md"
},
"plugins": ["plugins/markdown"],
"templates": {
"cleverLinks": true,
"monospaceLinks": false
}
}
Run with the config:
npx jsdoc -c jsdoc.config.json
Better Documentation Tools
The basic JSDoc HTML output is functional but dated. Modern alternatives include:
documentation.js generates cleaner output and supports modern JavaScript:
npm install documentation --save-dev
npx documentation build src/** -f html -o docs/
TypeDoc is designed for TypeScript but works with well-documented JavaScript:
npm install typedoc --save-dev
npx typedoc src/index.js
Docusaurus (which this tutorial is built with) can incorporate JSDoc output into a larger documentation site.
Adding Documentation Generation to Your Workflow
{
"scripts": {
"docs": "jsdoc -c jsdoc.config.json",
"docs:serve": "jsdoc -c jsdoc.config.json && npx serve docs/"
}
}
npm run docs # Generate documentation
npm run docs:serve # Generate and preview in browser
Example: From Code to Documentation
Given this source file:
// src/math-utils.js
/**
* A collection of mathematical utility functions.
* @module math-utils
*/
/**
* Clamps a number within a range.
*
* @param {number} value - The number to clamp.
* @param {number} min - The minimum allowed value.
* @param {number} max - The maximum allowed value.
* @returns {number} The clamped value.
*
* @example
* clamp(15, 0, 10);
* // Returns: 10
*
* @example
* clamp(-5, 0, 10);
* // Returns: 0
*
* @example
* clamp(5, 0, 10);
* // Returns: 5
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* Linearly interpolates between two values.
*
* @param {number} start - The start value.
* @param {number} end - The end value.
* @param {number} t - The interpolation factor (0 to 1).
* @returns {number} The interpolated value.
* @throws {RangeError} If t is not between 0 and 1.
*
* @example
* lerp(0, 100, 0.5);
* // Returns: 50
*/
function lerp(start, end, t) {
if (t < 0 || t > 1) {
throw new RangeError('Interpolation factor must be between 0 and 1');
}
return start + (end - start) * t;
}
module.exports = { clamp, lerp };
Running npx jsdoc src/math-utils.js -d docs/ generates an HTML page with:
- Module description
- Function signatures with parameter types
- Detailed parameter descriptions
- Return type information
- Exception documentation
- Code examples
- Links between related functions
This documentation is always up to date because it is generated directly from the source code comments.
Summary
Good comments are a skill that takes practice. Here are the principles to follow:
- Comment why, not what. The code shows what it does. Comments should explain the reasoning, context, and constraints that are not visible in the code itself.
- Self-documenting code reduces the need for comments. Use descriptive variable names, extract complex conditions into named variables, break large functions into small named functions, and use constants instead of magic numbers.
- Delete bad comments. Remove comments that restate the code, commented-out code (use Git instead), and misleading or outdated comments. A wrong comment is worse than no comment.
- Use JSDoc for public APIs, complex functions, and library code. Document parameters with
@param, return values with@returns, exceptions with@throws, and provide examples with@example. Use@typedeffor complex object shapes. - Use TODO, FIXME, and HACK tags to mark work in progress. Make them specific, actionable, and traceable with ticket numbers or assignees. Use editor extensions and linter rules to keep them visible.
- Generate documentation from JSDoc using tools like jsdoc, documentation.js, or TypeDoc. Automated documentation stays in sync with the code because it is built from the same source.
- The best codebase has relatively few comments, not because the developers were lazy, but because the code is clear enough to speak for itself, with comments reserved for the genuinely non-obvious parts.
With clean, well-commented code and automated formatting and linting, the next step in code quality is learning to recognize and avoid common anti-patterns, the deceptively clever coding practices that make code harder to understand and maintain.