Clean Code in JavaScript 2026: 15 Principles That Separate Juniors from Seniors
Writing code that works is the bare minimum. Writing code that other developers (including future you) can read, understand, and modify — that is the real skill. Clean code is not about following arbitrary style rules. It is about reducing cognitive load so that anyone on your team can open a file and know what it does in seconds, not minutes. These 15 principles will transform how you write JavaScript.
Clean code matters most in team environments. If you have been through the ESLint & Prettier guide, you already have automated formatting. This lesson goes beyond formatting into the design decisions that make code truly maintainable.
1. Naming Is Everything
Names are the most-read part of your code. A good name eliminates the need for comments and makes code self-documenting.
// BAD: what does this do?
const d = new Date();
const t = d.getTime();
const x = users.filter(u => u.a > 18 && u.s === 'active');
// GOOD: instantly clear
const now = new Date();
const timestamp = now.getTime();
const activeAdultUsers = users.filter(
user => user.age > 18 && user.status === 'active'
);
Rules for great names: Variables should be nouns (user, orderTotal, isLoggedIn). Functions should be verbs (getUser, calculateTotal, validateEmail). Booleans should read as questions (isActive, hasPermission, canEdit). Avoid abbreviations unless they are universally understood (url, id, api are fine; usr, btn, mgr are not).
Name length should match scope. Loop counters can be i. Module-level constants need descriptive names like MAX_RETRY_ATTEMPTS. If a name needs a comment to explain it, the name is wrong.
2. Functions Should Do One Thing
A function should do exactly one thing, do it well, and do it only. If you cannot describe what a function does without using the word “and,” it does too much.
// BAD: does three things
function processUserRegistration(userData) {
// Validate
if (!userData.email || !userData.password) throw new Error('Invalid');
if (userData.password.length < 8) throw new Error('Password too short');
// Save to database
const user = db.insert('users', {
email: userData.email,
password: bcrypt.hash(userData.password, 12),
});
// Send welcome email
emailService.send({
to: user.email,
subject: 'Welcome!',
body: renderTemplate('welcome', user),
});
return user;
}
// GOOD: each function has one responsibility
function validateRegistration(data) {
if (!data.email) throw new ValidationError('Email is required');
if (!data.password) throw new ValidationError('Password is required');
if (data.password.length < 8) throw new ValidationError('Password must be 8+ characters');
}
async function createUser(email, password) {
const hashedPassword = await bcrypt.hash(password, 12);
return db.insert('users', { email, password: hashedPassword });
}
async function sendWelcomeEmail(user) {
await emailService.send({
to: user.email,
subject: 'Welcome!',
body: renderTemplate('welcome', user),
});
}
// Compose them
async function registerUser(data) {
validateRegistration(data);
const user = await createUser(data.email, data.password);
await sendWelcomeEmail(user);
return user;
}
Small functions are easier to test, reuse, and name. If a function is longer than 20 lines, look for opportunities to extract sub-functions.
3. Eliminate Magic Numbers and Strings
// BAD: what do these numbers mean?
if (user.role === 2) { /* ... */ }
if (retries > 3) { /* ... */ }
setTimeout(callback, 86400000);
// GOOD: named constants
const ROLES = { ADMIN: 2, EDITOR: 1, VIEWER: 0 };
const MAX_RETRIES = 3;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
if (user.role === ROLES.ADMIN) { /* ... */ }
if (retries > MAX_RETRIES) { /* ... */ }
setTimeout(callback, ONE_DAY_MS);
Constants make your code readable and changeable. If you need to change the max retries from 3 to 5, you change one line instead of hunting through the entire codebase.
4. Use Early Returns
Early returns (also called guard clauses) eliminate nesting and make the "happy path" obvious.
// BAD: deeply nested
function processOrder(order) {
if (order) {
if (order.items.length > 0) {
if (order.status === 'pending') {
if (order.paymentVerified) {
// Actual logic buried 4 levels deep
return fulfillOrder(order);
} else {
throw new Error('Payment not verified');
}
} else {
throw new Error('Order not pending');
}
} else {
throw new Error('No items');
}
} else {
throw new Error('No order');
}
}
// GOOD: flat and scannable
function processOrder(order) {
if (!order) throw new Error('No order');
if (order.items.length === 0) throw new Error('No items');
if (order.status !== 'pending') throw new Error('Order not pending');
if (!order.paymentVerified) throw new Error('Payment not verified');
return fulfillOrder(order);
}
The early return version is half the lines, has zero nesting, and reads top-to-bottom. Each guard clause eliminates an invalid state, and the final line is the clean happy path.
5. DRY — But Not Too DRY
DRY (Don't Repeat Yourself) is important, but premature abstraction is worse than duplication. The rule of three helps: duplicate code once before abstracting. When you see the same pattern three times, then extract it.
// Duplication is fine when the context differs
function validateEmail(email) {
if (!email) return 'Email is required';
if (!email.includes('@')) return 'Invalid email format';
return null;
}
function validateUsername(username) {
if (!username) return 'Username is required';
if (username.length < 3) return 'Username must be 3+ characters';
return null;
}
// BAD: over-abstraction that's harder to read
function validateField(value, fieldName, rules) {
for (const rule of rules) {
const error = rule(value, fieldName);
if (error) return error;
}
return null;
}
The abstracted version is more "DRY" but less readable. Prefer clarity over cleverness. Code is read 10x more often than it is written — optimize for the reader.
6. Prefer Pure Functions
A pure function returns the same output for the same input and has no side effects. Pure functions are predictable, testable, and easy to reason about.
// IMPURE: modifies external state, depends on external variable
let discount = 0.1;
function calculatePrice(items) {
let total = 0;
for (const item of items) {
item.discountedPrice = item.price * (1 - discount); // Mutation!
total += item.discountedPrice;
}
return total;
}
// PURE: no side effects, explicit inputs
function calculateTotal(items, discountRate) {
return items.reduce(
(total, item) => total + item.price * (1 - discountRate),
0
);
}
// Pure version of getting discounted items
function applyDiscount(items, discountRate) {
return items.map(item => ({
...item,
discountedPrice: item.price * (1 - discountRate),
}));
}
Not everything can be pure — you need side effects for database writes, API calls, and DOM updates. But push side effects to the edges of your application and keep the core logic pure.
7. Handle Errors Properly
Good error handling means being specific about what can go wrong and providing useful context.
// BAD: swallowing errors silently
try {
await saveUser(data);
} catch (e) {
console.log('Error'); // What error? What data? What happened?
}
// BAD: catching everything
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const report = generateReport(orders);
await emailReport(user.email, report);
} catch (e) {
res.status(500).json({ error: 'Something went wrong' });
}
// GOOD: specific, contextual error handling
async function getUserOrders(userId) {
const user = await getUser(userId);
if (!user) throw new NotFoundError(`User ${userId} not found`);
try {
return await getOrders(user.id);
} catch (error) {
throw new Error(`Failed to fetch orders for user ${userId}: ${error.message}`);
}
}
8. Comments: Why, Not What
// BAD: comments that restate the code
// Increment counter by 1
counter++;
// Check if user is admin
if (user.role === 'admin') { ... }
// GOOD: comments that explain WHY
// Rate limiting uses a sliding window instead of fixed windows
// to prevent request bursts at window boundaries
const limiter = new SlidingWindowLimiter(100, '15m');
// Safari doesn't support smooth scrolling in iframes,
// so we fall back to instant scroll on WebKit browsers
const scrollBehavior = isWebKit ? 'instant' : 'smooth';
If you find yourself writing a comment to explain what code does, the code is not clear enough. Rename variables, extract functions, or simplify the logic until the code speaks for itself. Reserve comments for why — business rules, workarounds, and non-obvious design decisions.
9. Minimize Mutation
// BAD: mutation makes state unpredictable
function addTag(user, tag) {
user.tags.push(tag); // Mutates the original
return user;
}
// GOOD: return new objects
function addTag(user, tag) {
return {
...user,
tags: [...user.tags, tag],
};
}
// Use const for everything you can
const users = await fetchUsers();
const activeUsers = users.filter(u => u.isActive);
const userNames = activeUsers.map(u => u.name);
Use const by default. Only use let when you genuinely need to reassign. Never use var. Prefer .map(), .filter(), and .reduce() over loops that mutate accumulator variables.
10. Be Consistent
Consistency trumps personal preference. If the codebase uses camelCase function names, do not introduce snake_case. If existing async code uses async/await, do not switch to .then() chains. Match the patterns already established.
Enforce consistency with tools: ESLint for code patterns, Prettier for formatting, TypeScript for type consistency. Automated tools catch violations so code reviews can focus on logic and design.
11. Make Conditionals Readable
// BAD: complex inline condition
if (user.age >= 18 && user.verified && !user.banned && user.subscription !== 'expired') {
grantAccess(user);
}
// GOOD: extract to a named function
function canAccessContent(user) {
if (user.age < 18) return false;
if (!user.verified) return false;
if (user.banned) return false;
if (user.subscription === 'expired') return false;
return true;
}
if (canAccessContent(user)) {
grantAccess(user);
}
The named function reads like English and is reusable. It also makes each condition independently testable.
12. Flatten Deep Nesting
Every level of nesting increases cognitive load. Flatten code with early returns, extracted functions, and async/await instead of nested callbacks.
// BAD: callback hell
getUser(id, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
processOrders(orders, (err, result) => {
if (err) return handleError(err);
sendReport(result);
});
});
});
// GOOD: flat async/await
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const result = await processOrders(orders);
await sendReport(result);
} catch (error) {
handleError(error);
}
13. Organize Files by Feature
// BAD: organized by type (makes you jump between folders)
src/
controllers/
userController.js
orderController.js
models/
userModel.js
orderModel.js
routes/
userRoutes.js
orderRoutes.js
// GOOD: organized by feature (everything related is together)
src/
users/
user.controller.js
user.model.js
user.routes.js
user.test.js
user.validation.js
orders/
order.controller.js
order.model.js
order.routes.js
order.test.js
Feature-based organization means adding a new feature requires touching one folder instead of scattering files across five. When you delete a feature, you delete one folder. This scales far better for growing applications.
14. Refactor Continuously
Refactoring is not a separate task — it is part of every feature. The Boy Scout Rule: leave the code cleaner than you found it. Every time you touch a file, improve one small thing: rename a confusing variable, extract a helper function, add a missing type annotation.
Refactoring requires tests. Without tests, refactoring is gambling. With tests, you can restructure code confidently, knowing that if the tests pass, the behavior is preserved.
15. Write Tests That Document Behavior
// BAD: test names that say nothing
test('test1', () => { ... });
test('should work', () => { ... });
// GOOD: tests are documentation
describe('calculateShippingCost', () => {
test('returns 0 for orders over $50', () => {
expect(calculateShippingCost(75)).toBe(0);
});
test('returns flat rate of $5.99 for orders under $50', () => {
expect(calculateShippingCost(30)).toBe(5.99);
});
test('throws for negative amounts', () => {
expect(() => calculateShippingCost(-10)).toThrow('Invalid amount');
});
});
Good test descriptions explain the business rules. A developer reading these tests understands the shipping cost logic without reading the implementation. Tests are the most trustworthy documentation because they are verified every time you run them.
Clean code is not a destination — it is a practice. Start with naming and small functions. Add early returns and eliminate magic numbers. Over time, these habits become automatic, and your code becomes a pleasure to work with instead of a burden to maintain. The best codebases are not written by geniuses — they are written by disciplined developers who care about the next person who reads their code.