Skip to main content

How to Avoid JavaScript Anti-Patterns and "Ninja Code"

There is a certain pride developers feel when they write a one-liner that does the work of ten lines. A deeply nested ternary, a chain of bitwise operations, a variable named _ that somehow makes everything work. It feels clever. It feels efficient. And it is almost always a mistake.

"Ninja code" is code that is intentionally obscure, overly compact, or needlessly clever. It might impress you in the moment, but it punishes everyone who reads it later, including your future self. Anti-patterns are recurring bad practices that seem reasonable on the surface but create maintenance nightmares, introduce bugs, and make codebases progressively harder to work with.

This guide catalogs the most common JavaScript anti-patterns with real examples, teaches you to recognize code smells before they become serious problems, and introduces the principle that should guide every line you write: the Principle of Least Surprise.

Overly Clever Code and Why It Is Harmful

The Temptation of Cleverness

Clever code compresses logic into the fewest possible characters or uses obscure language features to accomplish tasks in unexpected ways. The developer who wrote it feels smart. Everyone else feels confused.

// "Clever" way to swap two variables
a ^= b; b ^= a; a ^= b;

// Clear way to swap two variables
[a, b] = [b, a];
// "Clever" way to get a random boolean
const flip = !!(Math.random() * 2 | 0);

// Clear way to get a random boolean
const flip = Math.random() < 0.5;
// "Clever" way to flatten and deduplicate
const result = [...new Set([].concat(...arrays))];

// Clear way to flatten and deduplicate
const flattened = arrays.flat();
const unique = [...new Set(flattened)];

Each clever version requires the reader to decode what is happening. The clear versions communicate intent immediately.

The Real Cost

The cost of clever code is measured in time. Every developer who encounters it must:

  1. Stop and decode what the code does (minutes to hours)
  2. Verify that their understanding is correct (testing, re-reading)
  3. Fear modifying it because they do not fully trust their understanding
  4. Rewrite it anyway when they need to change behavior

A line of code is written once but read hundreds of times. Optimizing for write-time at the expense of read-time is always the wrong trade-off.

The Rule

Code should be optimized for the reader, not the writer.

If a more verbose version is easier to understand,
it is the better code, even if it takes more lines.
tip

Debugging is twice as hard as writing code. If you write code at the maximum cleverness you can manage, you are by definition not clever enough to debug it. Always write code that is slightly below your maximum capability, leaving room for the debugging that will inevitably follow.

Common Anti-Patterns with Real Examples

Anti-Pattern 1: Cryptic Variable Names

Using single letters, abbreviations, or meaningless names forces readers to track mental mappings throughout the code.

// BAD: what are d, t, r, and f?
function proc(d) {
const t = d.reduce((a, c) => a + c.p * c.q, 0);
const r = t > 100 ? t * 0.9 : t;
const f = r * 1.2;
return f;
}

// GOOD: names explain everything
function calculateOrderTotal(items) {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
const discountedTotal = subtotal > 100 ? subtotal * 0.9 : subtotal;
const totalWithTax = discountedTotal * 1.2;
return totalWithTax;
}

The bad version requires you to trace every variable to understand what proc does. The good version reads like English. You understand it in seconds.

Common offenders:

// BAD: generic, meaningless names
let data = getData();
let result = process(data);
let temp = transform(result);
let info = format(temp);
let val = calculate(info);

// BAD: single letters outside of tiny loops
let a = getUser();
let b = a.permissions;
let c = b.filter(p => p.active);

// GOOD: descriptive, specific names
let currentUser = getUser();
let userPermissions = currentUser.permissions;
let activePermissions = userPermissions.filter(p => p.active);

Anti-Pattern 2: Functions That Do Too Many Things

A function should do one thing. When a function handles validation, transformation, storage, and notification all at once, it becomes impossible to test, reuse, or modify safely.

// BAD: one function doing everything
function handleRegistration(formData) {
// Validate
if (!formData.email || !formData.email.includes('@')) {
showError('Invalid email');
return;
}
if (formData.password.length < 8) {
showError('Password too short');
return;
}
if (formData.password !== formData.confirmPassword) {
showError('Passwords do not match');
return;
}

// Transform
const user = {
email: formData.email.toLowerCase().trim(),
password: hashPassword(formData.password),
name: formData.name.trim(),
createdAt: new Date(),
};

// Store
const savedUser = database.users.insert(user);

// Notify
sendWelcomeEmail(savedUser.email, savedUser.name);
analytics.track('user_registered', { userId: savedUser.id });
showSuccess('Registration complete!');

// Redirect
window.location.href = '/dashboard';
}
// GOOD: each function has a single responsibility
function handleRegistration(formData) {
const errors = validateRegistration(formData);
if (errors.length > 0) {
showErrors(errors);
return;
}

const user = createUserFromForm(formData);
const savedUser = saveUser(user);
onRegistrationComplete(savedUser);
}

function validateRegistration(formData) {
const errors = [];
if (!formData.email || !formData.email.includes('@')) {
errors.push('Invalid email');
}
if (formData.password.length < 8) {
errors.push('Password too short');
}
if (formData.password !== formData.confirmPassword) {
errors.push('Passwords do not match');
}
return errors;
}

function createUserFromForm(formData) {
return {
email: formData.email.toLowerCase().trim(),
password: hashPassword(formData.password),
name: formData.name.trim(),
createdAt: new Date(),
};
}

function saveUser(user) {
return database.users.insert(user);
}

function onRegistrationComplete(user) {
sendWelcomeEmail(user.email, user.name);
analytics.track('user_registered', { userId: user.id });
showSuccess('Registration complete!');
window.location.href = '/dashboard';
}

Now each function can be tested independently, reused elsewhere, and modified without risk of breaking unrelated functionality.

Anti-Pattern 3: Deeply Nested Code

Deep nesting makes code hard to follow because your brain must track multiple active conditions simultaneously.

// BAD: deeply nested logic
function processPayment(order) {
if (order) {
if (order.items.length > 0) {
if (order.customer) {
if (order.customer.paymentMethod) {
if (order.customer.paymentMethod.isValid) {
if (order.total > 0) {
if (order.total <= order.customer.balance) {
// Finally! The actual logic, buried 7 levels deep
chargeCustomer(order.customer, order.total);
return { success: true };
} else {
return { success: false, error: 'Insufficient funds' };
}
} else {
return { success: false, error: 'Invalid total' };
}
} else {
return { success: false, error: 'Invalid payment method' };
}
} else {
return { success: false, error: 'No payment method' };
}
} else {
return { success: false, error: 'No customer' };
}
} else {
return { success: false, error: 'Empty order' };
}
} else {
return { success: false, error: 'No order' };
}
}
// GOOD: guard clauses flatten the nesting
function processPayment(order) {
if (!order) {
return { success: false, error: 'No order' };
}
if (order.items.length === 0) {
return { success: false, error: 'Empty order' };
}
if (!order.customer) {
return { success: false, error: 'No customer' };
}
if (!order.customer.paymentMethod) {
return { success: false, error: 'No payment method' };
}
if (!order.customer.paymentMethod.isValid) {
return { success: false, error: 'Invalid payment method' };
}
if (order.total <= 0) {
return { success: false, error: 'Invalid total' };
}
if (order.total > order.customer.balance) {
return { success: false, error: 'Insufficient funds' };
}

chargeCustomer(order.customer, order.total);
return { success: true };
}

Both functions have exactly the same behavior. The guard clause version is flat, reads top-to-bottom, and the main logic is at the bottom with no indentation overhead.

Anti-Pattern 4: Overusing Ternaries

A single ternary is readable. Nested ternaries become a puzzle.

// BAD: nested ternaries that require decoding
const label = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';

// BAD: ternary with side effects
isValid ? submitForm() : showErrors(validate(form));

// BAD: ternary inside a ternary inside a template literal
const msg = `${user ? (user.isAdmin ? 'Admin' : 'User') : 'Guest'}: ${action}`;
// GOOD: formatted nested ternary (acceptable for simple value mapping)
const label = score >= 90 ? 'A'
: score >= 80 ? 'B'
: score >= 70 ? 'C'
: score >= 60 ? 'D'
: 'F';

// BETTER: use if/else for complex logic
let label;
if (score >= 90) label = 'A';
else if (score >= 80) label = 'B';
else if (score >= 70) label = 'C';
else if (score >= 60) label = 'D';
else label = 'F';

// BEST for this case: use a function
function getGrade(score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
if (score >= 70) return 'C';
if (score >= 60) return 'D';
return 'F';
}

Rule of thumb: Use ternaries for simple value assignments. Use if/else for anything with side effects, complex conditions, or more than one level of nesting.

Anti-Pattern 5: Mutating Function Arguments

When a function modifies the objects passed to it, callers cannot predict the side effects. Data flows become invisible.

// BAD: mutates the input object
function applyDiscount(order) {
order.total = order.total * 0.9; // Modifies the original!
order.discounted = true; // Adds a new property!
order.items.forEach((item) => { // Modifies nested objects!
item.price = item.price * 0.9;
});
return order;
}

const myOrder = { total: 100, items: [{ price: 50 }, { price: 50 }] };
const discounted = applyDiscount(myOrder);

console.log(myOrder.total); // 90 (the original was mutated!)
console.log(myOrder === discounted); // true (same object reference!)
// GOOD: returns a new object, input is unchanged
function applyDiscount(order) {
return {
...order,
total: order.total * 0.9,
discounted: true,
items: order.items.map((item) => ({
...item,
price: item.price * 0.9,
})),
};
}

const myOrder = { total: 100, items: [{ price: 50 }, { price: 50 }] };
const discounted = applyDiscount(myOrder);

console.log(myOrder.total); // 100 (untouched!)
console.log(myOrder === discounted); // false (different object)

Anti-Pattern 6: Overusing Short-Circuit for Control Flow

Using && and || as replacements for if statements makes code harder to read and debug.

// BAD: using && as an if statement
user && user.isAdmin && showAdminPanel();
data && data.length && processData(data);
!isLoading && items.length > 0 && renderList(items);

// BAD: using || as a fallback chain with side effects
getUserFromCache() || getUserFromDB() || createDefaultUser();
// GOOD: if statements are clear and debuggable
if (user && user.isAdmin) {
showAdminPanel();
}

if (data && data.length) {
processData(data);
}

if (!isLoading && items.length > 0) {
renderList(items);
}

Short-circuit operators are appropriate for value expressions (assigning defaults, returning values), not for control flow (executing side effects).

// APPROPRIATE use of short-circuit: value assignment
const name = user?.name || 'Anonymous';
const config = loadConfig() ?? defaultConfig;
const items = data?.items ?? [];

Anti-Pattern 7: Type Coercion Tricks

Using implicit type coercion for conversions makes code fragile and confusing.

// BAD: relying on type coercion tricks
const num = +userInput; // What if userInput is an object?
const str = '' + value; // Unclear intent
const bool = !!value; // Clever, but is Boolean(value) clearer?
const int = ~~floatValue; // Almost nobody knows what ~~ does
const int = value | 0; // Bitwise OR for truncation??
const arr = value + ''; // Converts anything to string... somehow

// GOOD: explicit conversions
const num = Number(userInput);
const str = String(value);
const bool = Boolean(value);
const int = Math.trunc(floatValue);
const text = String(value);

The explicit versions state exactly what conversion is happening. The implicit versions require knowledge of JavaScript quirks.

Anti-Pattern 8: God Objects and God Functions

A "God object" knows too much and does too much. It becomes a bottleneck where every change requires understanding the entire object.

// BAD: one object/class that does everything
const app = {
users: [],
products: [],
orders: [],
settings: {},

addUser(user) { /* ... */ },
removeUser(id) { /* ... */ },
updateUser(id, data) { /* ... */ },
authenticateUser(email, password) { /* ... */ },

addProduct(product) { /* ... */ },
removeProduct(id) { /* ... */ },
updateProduct(id, data) { /* ... */ },
searchProducts(query) { /* ... */ },

createOrder(userId, items) { /* ... */ },
cancelOrder(orderId) { /* ... */ },
refundOrder(orderId) { /* ... */ },

updateSettings(newSettings) { /* ... */ },
validateSettings(settings) { /* ... */ },
exportData(format) { /* ... */ },
importData(data) { /* ... */ },
generateReport(type) { /* ... */ },
sendEmail(to, subject, body) { /* ... */ },
logEvent(event) { /* ... */ },
};
// GOOD: separate modules with clear boundaries
const userService = {
add(user) { /* ... */ },
remove(id) { /* ... */ },
update(id, data) { /* ... */ },
authenticate(email, password) { /* ... */ },
};

const productService = {
add(product) { /* ... */ },
remove(id) { /* ... */ },
update(id, data) { /* ... */ },
search(query) { /* ... */ },
};

const orderService = {
create(userId, items) { /* ... */ },
cancel(orderId) { /* ... */ },
refund(orderId) { /* ... */ },
};

Each module has a focused responsibility, can be tested independently, and can be understood without reading hundreds of lines.

Code Smells and How to Recognize Them

A code smell is not a bug. It is a surface-level indicator that something deeper might be wrong. Like a strange noise from your car's engine, it does not necessarily mean something is broken, but it warrants investigation.

Smell 1: Repeated Code

If you see the same logic in multiple places, it should be extracted into a function.

// SMELL: same validation logic in three places
function createUser(data) {
if (!data.email || !data.email.includes('@') || data.email.length > 254) {
throw new Error('Invalid email');
}
// ...
}

function updateUser(id, data) {
if (!data.email || !data.email.includes('@') || data.email.length > 254) {
throw new Error('Invalid email');
}
// ...
}

function inviteUser(email) {
if (!email || !email.includes('@') || email.length > 254) {
throw new Error('Invalid email');
}
// ...
}

// FIX: extract into a reusable function
function validateEmail(email) {
if (!email || !email.includes('@') || email.length > 254) {
throw new Error('Invalid email');
}
}

function createUser(data) {
validateEmail(data.email);
// ...
}

Smell 2: Magic Numbers and Strings

Unexplained literal values scattered through code.

// SMELL: what do these numbers mean?
if (response.status === 429) {
await delay(60000);
retry();
}

if (password.length < 8) { }

if (items.length > 50) {
paginate(items);
}

// FIX: name the values
const HTTP_TOO_MANY_REQUESTS = 429;
const ONE_MINUTE_MS = 60000;
const MIN_PASSWORD_LENGTH = 8;
const MAX_ITEMS_PER_PAGE = 50;

if (response.status === HTTP_TOO_MANY_REQUESTS) {
await delay(ONE_MINUTE_MS);
retry();
}

if (password.length < MIN_PASSWORD_LENGTH) { }

if (items.length > MAX_ITEMS_PER_PAGE) {
paginate(items);
}

Smell 3: Long Parameter Lists

Functions with many parameters are hard to call correctly and hard to understand.

// SMELL: too many parameters, easy to mix up order
function createEvent(title, date, startTime, endTime, location,
isRecurring, recurrencePattern, organizer,
attendees, description, isPrivate, reminder) {
// Which parameter is which? Caller has to count positions.
}

createEvent('Meeting', '2024-01-15', '10:00', '11:00', 'Room A',
true, 'weekly', 'alice@co.com', ['bob@co.com'],
'Weekly sync', false, 15); // Is 15 the reminder? or isPrivate?

// FIX: use an options object
function createEvent({
title,
date,
startTime,
endTime,
location,
isRecurring = false,
recurrencePattern = null,
organizer,
attendees = [],
description = '',
isPrivate = false,
reminderMinutes = null,
}) {
// Each parameter is named, order doesn't matter
}

createEvent({
title: 'Meeting',
date: '2024-01-15',
startTime: '10:00',
endTime: '11:00',
location: 'Room A',
isRecurring: true,
recurrencePattern: 'weekly',
organizer: 'alice@co.com',
attendees: ['bob@co.com'],
description: 'Weekly sync',
reminderMinutes: 15,
});

Smell 4: Boolean Parameters That Change Behavior

A boolean parameter often means the function does two different things.

// SMELL: what does 'true' mean here?
renderList(items, true);
sendEmail(user, message, false, true);
createUser(data, true, false);

// What do those booleans mean without reading the function signature?
// FIX: use descriptive options or separate functions

// Option 1: options object with named properties
renderList(items, { showArchived: true });
sendEmail(user, message, { isHtml: false, sendCopy: true });

// Option 2: separate functions for different behaviors
renderActiveList(items);
renderArchivedList(items);

sendPlainTextEmail(user, message);
sendHtmlEmail(user, message);

Smell 5: Comments That Explain Bad Code

If code needs comments to explain what it does, the code should be rewritten, not commented.

// SMELL: comment is a band-aid for unclear code
// Check if the user can access the premium feature
if ((u.s === 'a' || u.s === 't') && u.p > 2 && !u.b && u.v) { }
// FIX: make the code self-explanatory
const isActiveOrTrialing = user.status === 'active' || user.status === 'trial';
const hasPremiumTier = user.plan > 2;
const isNotBanned = !user.banned;
const isVerified = user.verified;

if (isActiveOrTrialing && hasPremiumTier && isNotBanned && isVerified) { }

Smell 6: Inconsistent Error Handling

Mixing different error handling approaches in the same codebase.

// SMELL: inconsistent approaches throughout the codebase
function getUser(id) {
// Sometimes returns null
return users.find((u) => u.id === id) || null;
}

function getProduct(id) {
// Sometimes throws
const product = products.find((p) => p.id === id);
if (!product) throw new Error('Product not found');
return product;
}

function getOrder(id) {
// Sometimes returns an error object
const order = orders.find((o) => o.id === id);
if (!order) return { error: 'Not found', data: null };
return { error: null, data: order };
}

function getSettings(key) {
// Sometimes returns undefined
return settingsMap[key];
}

Each function uses a different convention. The caller must check the source code of every function to know how to handle failures.

// FIX: consistent approach throughout the codebase
// Pick ONE pattern and use it everywhere

// Pattern: return null for "not found", throw for actual errors
function getUser(id) {
if (typeof id !== 'number') {
throw new TypeError('User ID must be a number');
}
return users.find((u) => u.id === id) ?? null;
}

function getProduct(id) {
if (typeof id !== 'number') {
throw new TypeError('Product ID must be a number');
}
return products.find((p) => p.id === id) ?? null;
}

function getOrder(id) {
if (typeof id !== 'number') {
throw new TypeError('Order ID must be a number');
}
return orders.find((o) => o.id === id) ?? null;
}

Code Smell Quick Reference

SmellSymptomFix
Repeated codeSame logic in multiple placesExtract into a function
Magic valuesUnexplained numbers/stringsNamed constants
Long parameter lists4+ parametersOptions object
Boolean parametersfn(data, true, false)Named options or separate functions
Deep nesting3+ indent levelsGuard clauses, extract functions
God object/functionDoes too many thingsSplit by responsibility
Comments explaining code"This calculates..."Rewrite the code to be clearer
Inconsistent patternsDifferent conventions mixedStandardize one approach
Long functions30+ linesExtract smaller functions
Feature envyFunction uses another object's data more than its ownMove the function to that object

The Principle of Least Surprise

The Principle of Least Surprise (also called the Principle of Least Astonishment) states:

A component of a system should behave in a way that most users will expect it to behave. The behavior should not astonish or surprise users.

In programming, this means your code should do what the reader expects based on its name, context, and conventions. When code surprises the reader, bugs follow.

Surprising Function Names

// SURPRISING: name says "get" but it modifies data
function getFullName(user) {
user.fullName = `${user.firstName} ${user.lastName}`; // Side effect!
return user.fullName;
}

// EXPECTED: "get" just returns a value, no side effects
function getFullName(user) {
return `${user.firstName} ${user.lastName}`;
}
// SURPRISING: name says "validate" but it also transforms
function validateEmail(email) {
const cleaned = email.trim().toLowerCase();
if (!cleaned.includes('@')) return null;
return cleaned; // Returns the cleaned email, not a boolean!
}

// EXPECTED: validate returns boolean, separate function transforms
function isValidEmail(email) {
return email.includes('@');
}

function normalizeEmail(email) {
return email.trim().toLowerCase();
}

Surprising Function Behavior

// SURPRISING: sort() mutates the original array AND returns it
const original = [3, 1, 2];
const sorted = original.sort();
console.log(original); // [1, 2, 3] (original was mutated!)
console.log(original === sorted); // true (same array!)

// This is actually how JavaScript's built-in Array.sort() works.
// It surprises many developers. ES2023 introduced toSorted() to fix this:
const sorted = original.toSorted(); // Returns a new array, original untouched
// SURPRISING: function has hidden dependencies
let currentUser = null;

function isAuthorized(action) {
// Where does currentUser come from? Who sets it? When?
return currentUser && currentUser.permissions.includes(action);
}

// EXPECTED: all inputs are explicit
function isAuthorized(user, action) {
return user && user.permissions.includes(action);
}

Surprising Return Values

// SURPRISING: returns different types depending on input
function findUser(query) {
if (typeof query === 'number') {
return users.find((u) => u.id === query); // Returns object or undefined
}
if (typeof query === 'string') {
return users.filter((u) => u.name.includes(query)); // Returns array!
}
return null; // Returns null!
}

// The caller has to handle three different return types!
const result = findUser(42); // Object | undefined
const result = findUser('Ali'); // Array
const result = findUser(true); // null

// EXPECTED: consistent return types
function findUserById(id) {
return users.find((u) => u.id === id) ?? null; // Always: Object | null
}

function searchUsersByName(query) {
return users.filter((u) => u.name.includes(query)); // Always: Array
}

Surprising Side Effects

// SURPRISING: a "pure" calculation function that logs and writes to storage
function calculateTax(amount, rate) {
const tax = amount * rate;
console.log(`Tax calculated: ${tax}`); // Why is this logging?
localStorage.setItem('lastTax', tax); // Why is this saving?
analytics.track('tax_calculated', { amount, tax }); // Why is this tracking?
return tax;
}

// EXPECTED: pure function does only what its name says
function calculateTax(amount, rate) {
return amount * rate;
}

Surprising Naming Conventions

// SURPRISING: inconsistent naming within the same codebase
function getUsers() { } // Returns an array
function fetchProducts() { } // Also returns an array (why different verb?)
function loadOrders() { } // Also returns an array (yet another verb?)
function retrieveInvoices() { } // Same thing, fourth verb

// EXPECTED: consistent verbs for consistent operations
function getUsers() { } // All use "get" for retrieval
function getProducts() { }
function getOrders() { }
function getInvoices() { }

Applying the Principle

Before writing any function, class, or module, ask yourself:

If another developer sees this name, what will they expect?
├── Does my implementation match that expectation?
│ ├── Yes → Good. Ship it.
│ └── No → Either rename it or change the implementation.

What side effects will surprise the caller?
├── None → Good.
└── Some → Either remove them, document them prominently,
or restructure so the name communicates the effects.

Practical guidelines derived from this principle:

  • Functions named get*, find*, calculate*, is*, has* should be pure (no side effects)
  • Functions named set*, update*, save*, delete*, send* are expected to have side effects
  • Functions should return consistent types (not sometimes an array, sometimes an object)
  • Functions should not modify their inputs unless their name clearly implies mutation (like sortInPlace)
  • Error handling should follow one consistent pattern across the entire codebase
  • Naming conventions (verbs, prefixes, suffixes) should be used consistently across all modules

Summary

Anti-patterns and ninja code create a false sense of productivity. The developer saves a few minutes writing clever code, and the team loses hours reading, understanding, debugging, and eventually rewriting it:

  • Overly clever code is written for the writer, not the reader. Optimize for readability. If a more verbose version is easier to understand, it is the better code.
  • Common anti-patterns include cryptic variable names, functions that do too many things, deeply nested conditionals, nested ternaries, mutating function arguments, using short-circuit operators for control flow, relying on type coercion tricks, and creating god objects.
  • Code smells are surface indicators of deeper problems: repeated code, magic numbers, long parameter lists, boolean parameters, inconsistent error handling, and comments that explain unclear code. Each has a systematic fix.
  • The Principle of Least Surprise states that code should behave the way a reader expects. Functions should do what their names say, return consistent types, avoid hidden side effects, and follow the conventions established in the codebase.
  • The best code is boring code: obvious naming, simple structure, no tricks, no surprises. It does not impress anyone when they read it, and that is exactly the point. It works, it is clear, and it stays out of the way.

With clean style, good comments, and anti-pattern awareness, the next step in code quality is automated testing, where you write code that verifies your code works correctly and keeps working as you make changes.