Code is read far more often than it is written. Every else block, every deeply nested if, every multi-line switch arm is a tax levied on every developer who opens that file after you — including yourself six months from now.
This post covers the principles I follow on every project to keep code clean, readable, and maintainable.
1. Guard Clauses: Eliminate else Forever
The most impactful habit you can build is using guard clauses — returning or throwing early when a condition isn't met, rather than wrapping the correct path in an if block.
The Problem With else
// ❌ Nested — the correct path is buried inside two layers
function processPayment(user: User, amount: number): Result {
if (user) {
if (user.isActive) {
if (amount > 0) {
const result = chargeCard(user, amount);
return { success: true, result };
} else {
return { success: false, error: "Amount must be positive" };
}
} else {
return { success: false, error: "User account is inactive" };
}
} else {
return { success: false, error: "User not found" };
}
}
Reading this forces your brain to track three levels of nesting simultaneously. The actual business logic — chargeCard — is invisible until you've parsed everything around it.
The Fix: Guard Clauses
// ✅ Guard clauses — failure cases exit immediately
function processPayment(user: User, amount: number): Result {
if (!user) return { success: false, error: "User not found" };
if (!user.isActive) return { success: false, error: "User account is inactive" };
if (amount <= 0) return { success: false, error: "Amount must be positive" };
const result = chargeCard(user, amount);
return { success: true, result };
}
The rules are simple:
- Check for failure first. Return immediately if any precondition isn't met.
- The correct path is the last thing in the function. No indentation, no nesting.
- Never use
elseafter areturn. It's always redundant.
else After return Is Always Dead Weight
// ❌ The else here is completely pointless
function getDiscount(tier: string): number {
if (tier === "premium") {
return 0.2;
} else {
return 0;
}
}
// ✅ The else cannot be reached anyway
function getDiscount(tier: string): number {
if (tier === "premium") return 0.2;
return 0;
}
Once you return, the function is done. The else branch is never reachable — remove it every time.
2. Prefer Guard Clauses Over switch
switch is not inherently wrong. When every case is a single-line return, it is perfectly readable:
// ✅ Acceptable switch — every case is a single line
function getStatusLabel(status: string): string {
switch (status) {
case "active": return "Active";
case "inactive": return "Inactive";
case "pending": return "Pending Review";
default: return "Unknown";
}
}
The problem is that switch always costs you one level of indentation for the switch block itself. The moment a case body grows beyond a single return — a variable declaration, a helper call, an early guard — you are now two levels deep inside the function before you have written a single line of real logic.
Guard clauses have a hard maximum: one level of indentation, regardless of how complex the branch becomes:
// ✅ Guard clauses — same logic, maximum one level of indentation
function getStatusLabel(status: string): string {
if (status === "active") return "Active";
if (status === "inactive") return "Inactive";
if (status === "pending") return "Pending Review";
return "Unknown";
}
The difference becomes obvious the moment a branch needs more than one line:
// ❌ switch — complex case forces two levels of nesting
function processStatus(status: string, user: User): string {
switch (status) {
case "pending": {
const label = buildPendingLabel(user);
return label;
}
default:
return "Unknown";
}
}
// ✅ Guard clause — complex branch stays at one level
function processStatus(status: string, user: User): string {
if (status === "pending") {
const label = buildPendingLabel(user);
return label;
}
return "Unknown";
}
Use guard clauses by default. Reserve switch for the cases where every arm is genuinely a one-liner and the parallel structure makes the intent clearer at a glance.
Object Maps for Large Sets
When you have many cases — more than four or five — both guard clauses and switch start to feel like a long list of decisions. An object map turns that logic into data, which is far easier to extend:
// ✅ Object map — adding a new status is a one-liner
const STATUS_LABELS: Record<string, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending Review",
suspended: "Suspended",
trial: "Free Trial",
};
function getStatusLabel(status: string): string {
return STATUS_LABELS[status] ?? "Unknown";
}
Object maps are especially powerful when the values are functions:
type Handler = (payload: Payload) => Promise<void>;
const EVENT_HANDLERS: Record<string, Handler> = {
"invoice.created": handleInvoiceCreated,
"invoice.paid": handleInvoicePaid,
"invoice.voided": handleInvoiceVoided,
"customer.updated": handleCustomerUpdated,
};
async function processEvent(type: string, payload: Payload): Promise<void> {
const handler = EVENT_HANDLERS[type];
if (!handler) return logUnknownEvent(type);
await handler(payload);
}
This pattern completely replaces a switch on event types — and unlike switch, adding a new handler is one line added to the map with zero restructuring required.
3. Keep Nesting Low — Extract Into Functions
Deep nesting is a sign that a block of code is doing too much. The fix is almost always to extract it into a named function.
The Rule of Thumb
If a block is more than two levels deep, it belongs in its own function with a descriptive name.
// ❌ Three levels of nesting — hard to follow
async function syncUserData(users: User[]): Promise<void> {
for (const user of users) {
if (user.isActive) {
const profile = await fetchProfile(user.id);
if (profile) {
const updatedProfile = {
...user,
avatar: profile.avatar,
bio: profile.bio,
};
await database.users.update(updatedProfile);
}
}
}
}
// ✅ Each function does one thing and is easy to test
async function syncUserData(users: User[]): Promise<void> {
const activeUsers = users.filter((u) => u.isActive);
await Promise.all(activeUsers.map(syncSingleUser));
}
async function syncSingleUser(user: User): Promise<void> {
const profile = await fetchProfile(user.id);
if (!profile) return;
await database.users.update(updateUserWithProfile(user, profile));
}
function updateUserWithProfile(user: User, profile: Profile): User {
return { ...user, avatar: profile.avatar, bio: profile.bio };
}
Each function now:
- Has a single, clear responsibility
- Can be read in under 5 seconds
- Can be tested in isolation
Early Returns in Loops
The same guard clause principle applies inside loops:
// ❌ Nesting inside a loop
for (const item of items) {
if (item.isValid) {
if (!item.isProcessed) {
processItem(item);
}
}
}
// ✅ Guard clauses flatten the loop body
for (const item of items) {
if (!item.isValid) continue;
if (item.isProcessed) continue;
processItem(item);
}
4. Other Readability Principles
Name Things After What They Do, Not What They Are
// ❌ Describes the type, not the intent
const data = await fetch("/api/users");
const list = data.filter(x => x.active);
// ✅ Describes the meaning
const allUsers = await fetch("/api/users");
const activeUsers = allUsers.filter((user) => user.isActive);
One Level of Abstraction Per Function
A function should either orchestrate high-level steps or handle low-level details — never both:
// ❌ Mixes orchestration with low-level detail
async function onboardUser(email: string): Promise<void> {
const user = await db.query(
`INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING *`,
[email]
);
await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
body: JSON.stringify({ to: email, subject: "Welcome!" }),
});
}
// ✅ High-level orchestration only — details are delegated
async function onboardUser(email: string): Promise<void> {
const user = await createUser(email);
await sendWelcomeEmail(user.email);
}
Avoid Magic Numbers and Strings
// ❌ What does 3 mean? What is "ADM"?
if (user.role === "ADM" && attempts < 3) { ... }
// ✅ Self-documenting constants
const MAX_LOGIN_ATTEMPTS = 3;
const ADMIN_ROLE = "ADM";
if (user.role === ADMIN_ROLE && attempts < MAX_LOGIN_ATTEMPTS) { ... }
Boolean Variable Names Should Be Questions
// ❌ You can't tell if this is true or false from the name
const active = user.status === "active";
const cache = settings.cacheEnabled;
// ✅ Reads like a natural sentence
const isActive = user.status === "active";
const isCacheEnabled = settings.cacheEnabled;
5. Naming: Be Descriptive, Never Abbreviate
A variable name is a contract with the next developer. It promises to tell them exactly what the value represents — without requiring them to scroll up to find the declaration, trace a function call, or guess from context.
When you abbreviate, you break that contract.
Abbreviations Are a Form of Obfuscation
// ❌ What is ctx? What is req? What is opts? What is cb?
async function handle(ctx: any, req: any, opts: any, cb: any) {
const usr = await ctx.db.find(req.id);
if (!usr) return cb(opts.err);
return cb(null, usr);
}
// ✅ Every name describes itself
async function handleUserRequest(
context: RequestContext,
request: UserRequest,
options: HandlerOptions,
callback: UserCallback
) {
const user = await context.database.findById(request.userId);
if (!user) return callback(options.notFoundError);
return callback(null, user);
}
The second version can be read by a developer who has never seen this codebase before. The first requires them to hunt down type definitions and trace call sites just to understand what is happening.
Common Abbreviations to Eliminate
| Abbreviation | Write this instead |
|---|---|
| ctx | context |
| req, res | request, response |
| cb | callback |
| err | error |
| cfg | config or configuration |
| msg | message |
| btn | button |
| val | value |
| idx | index |
| arr | a descriptive plural: userIds, invoiceItems, activeUsers |
| fn | handler, callback, or the specific function role |
| tmp, temp | a name describing what it actually holds |
| data | the specific thing it is: userData, invoicePayload, apiResponse |
| res | result, response, or the specific resolved value |
The last one — data — is especially worth calling out. It is arguably the most common meaningless variable name in all of programming:
// ❌ data tells you nothing
const data = await fetchUser(id);
const data2 = await fetchOrders(id);
processData(data, data2);
// ✅ Every name explains its content
const user = await fetchUser(id);
const recentOrders = await fetchOrders(id);
processUserOrders(user, recentOrders);
Always Include the Unit for Time and Measurement
This is one of the most important naming habits you can build, and one of the most commonly ignored.
When a variable represents a number with an associated unit — especially time — that unit must be part of the name. A bare timeout or delay is ambiguous. Is it milliseconds? Seconds? Minutes?
// ❌ Completely ambiguous — is this seconds? milliseconds?
const timeout = 5000;
const delay = 30;
const interval = 60;
const maxAge = 3600;
setTimeout(callback, timeout); // Will this fire in 5 seconds or 83 minutes?
// ✅ The unit is unambiguous — no comment required
const timeoutMilliseconds = 5000;
const delaySeconds = 30;
const pollingIntervalSeconds = 60;
const sessionMaxAgeSeconds = 3600;
setTimeout(callback, timeoutMilliseconds);
This pattern extends beyond time to any quantity with a unit:
// ❌ Is this bytes, kilobytes, megabytes?
const maxFileSize = 5242880;
const minDistance = 100;
const threshold = 0.85;
// ✅ Self-explanatory
const maxFileSizeBytes = 5_242_880;
const minDistanceMeters = 100;
const similarityThresholdPercent = 85;
Numeric Separators: Split Every Three Orders of Magnitude
Notice the use of 5_242_880 instead of 5242880 in the example above. This is a numeric separator — an underscore placed between digit groups to make large numbers scannable at a glance.
Most modern languages support this syntax natively. The underscore is purely cosmetic; it has no effect on the value:
// ❌ How many zeros is that? You have to count.
const maxFileSizeBytes = 5242880;
const requestsPerDay = 86400000;
const nationalDebtUSD = 5300000000000;
// ✅ Readable at a glance — no counting required
const maxFileSizeBytes = 5_242_880;
const requestsPerDay = 86_400_000;
const nationalDebtUSD = 5_300_000_000_000;
The rule is straightforward: group digits in sets of every three orders of magnitude, matching how you would read the number aloud — thousands, millions, billions.
This is supported in:
| Language | Syntax |
|---|---|
| TypeScript / JavaScript | 1_000_000 |
| Python | 1_000_000 |
| Rust | 1_000_000 |
| C# | 1_000_000 |
| Java | 1_000_000 |
| Swift | 1_000_000 |
| Kotlin | 1_000_000 |
As a general rule: any literal number with more than four digits should use separators. The cost is zero. The readability gain is immediate.
Class and Type Names: Full Words, Role First
Classes and types follow the same rule. The name should tell you what the thing is and does — without ambiguity:
// ❌ Abbreviated, unclear role
class UsrMgr { ... }
type AuthCfg = { ... }
interface ReqHandler { ... }
// ✅ Full words, role clear at a glance
class UserManager { ... }
type AuthenticationConfig = { ... }
interface RequestHandler { ... }
For classes that represent a pattern, make the pattern explicit in the name:
// ✅ The suffix tells you the role immediately
class EmailService { ... }
class PaymentRepository { ... }
class InvoiceSyncWorker { ... }
class UserController { ... }
class DatabaseConnectionFactory { ... }
A developer opening an unfamiliar file should be able to understand the architecture from names alone — without reading a single line of implementation.
The Self-Documenting Name Test
Before committing any variable, class, or function name, apply this test:
If someone reads this name in isolation — with no surrounding context, no type hints, no comments — can they tell exactly what it represents?
If the answer is no, rename it. The goal is a codebase where names are so clear that the code reads like well-written prose — and the only comments needed are those that explain why, not what.
6. Modularity and the Module Manager Pattern
As systems grow, a single monolithic codebase becomes fragile. Disabling a feature for testing, rolling back a bad deployment, or running the system with parts swapped out becomes dangerous.
The Module Manager pattern solves this by making every major feature a self-contained, registerable module that can be enabled or disabled independently.
What Is a Module?
A module is a unit of functionality that:
- Has a clear, single responsibility
- Registers itself with a central manager
- Can be enabled or disabled without affecting other modules
- Exposes a consistent interface (usually
init/destroy)
// lib/module.ts — the interface every module must implement
export interface AppModule {
readonly name: string;
init(): Promise<void>;
destroy?(): Promise<void>;
}
The Module Manager
// lib/module-manager.ts
import type { AppModule } from "./module";
export class ModuleManager {
private readonly modules = new Map<string, AppModule>();
private readonly disabled: Set<string>;
constructor(disabledModules: string[] = []) {
this.disabled = new Set(disabledModules);
}
register(module: AppModule): this {
if (this.disabled.has(module.name)) return this;
this.modules.set(module.name, module);
return this;
}
async initAll(): Promise<void> {
for (const module of this.modules.values()) {
console.log(`[ModuleManager] Initialising: ${module.name}`);
await module.init().catch((error) =>
console.error(`[ModuleManager] Failed to initialise "${module.name}":`, error)
);
}
}
async destroyAll(): Promise<void> {
for (const module of [...this.modules.values()].reverse()) {
await module.destroy?.()
.catch((error) =>
console.error(`[ModuleManager] Failed to destroy "${module.name}":`, error)
);
}
}
get(name: string): AppModule | undefined {
return this.modules.get(name);
}
}
Writing a Module
// modules/email.module.ts
import type { AppModule } from "@/lib/module";
import { EmailService } from "./email.service";
export class EmailModule implements AppModule {
readonly name = "email";
private service!: EmailService;
async init(): Promise<void> {
this.service = new EmailService(process.env.EMAIL_API_KEY!);
await this.service.connect();
console.log("[EmailModule] Ready");
}
async destroy(): Promise<void> {
await this.service.disconnect();
}
}
// modules/analytics.module.ts
import type { AppModule } from "@/lib/module";
export class AnalyticsModule implements AppModule {
readonly name = "analytics";
async init(): Promise<void> {
// set up analytics tracking
}
}
Bootstrapping the App
// app.ts — wiring it all together
import { ModuleManager } from "./lib/module-manager";
import { EmailModule } from "./modules/email.module";
import { AnalyticsModule } from "./modules/analytics.module";
import { PaymentsModule } from "./modules/payments.module";
import { WebhooksModule } from "./modules/webhooks.module";
// Read disabled modules from environment — no code changes needed
const disabled = (process.env.DISABLED_MODULES ?? "").split(",").filter(Boolean);
const manager = new ModuleManager(disabled);
manager
.register(new EmailModule())
.register(new AnalyticsModule())
.register(new PaymentsModule())
.register(new WebhooksModule());
await manager.initAll();
If one module fails to initialise — a bad API key, a misconfigured service, a network blip — the .catch inside initAll logs the error and moves on. The rest of your modules come up cleanly. Without this, a single broken module would throw and halt the entire boot sequence.
Now to disable the analytics module during local development, you just set an environment variable:
# .env.local
DISABLED_MODULES=analytics,webhooks
No code changes. No commenting out imports. No breaking anything else.
A Better Approach: Auto-Discovery
The manual bootstrap above works, but it has a scaling problem. Every time you create a new module you have to remember to import it and register it in app.ts. Miss one and it silently never loads.
A better design is to auto-discover modules — have the system find every file in the modules/ folder itself, without any manual wiring.
First, extend the AppModule interface with an isEnabled property so each module controls its own activation:
// lib/module.ts
export interface AppModule {
/** Unique identifier used for logging. */
readonly name: string;
/**
* Controls whether this module is loaded at startup.
* Set to false to skip without removing the file.
*/
readonly isEnabled: boolean;
init(): Promise<void>;
destroy?(): Promise<void>;
}
Then replace the manual bootstrap with an fs-based auto-loader that scans the modules folder, dynamically imports each file, and registers modules that report themselves as enabled:
// lib/bootstrap.ts
import fs from "fs";
import path from "path";
import { ModuleManager } from "./module-manager";
import type { AppModule } from "./module";
const MODULES_DIR = path.join(process.cwd(), "src", "modules");
/**
* Discovers all modules in the modules directory, registers the enabled
* ones with the manager, and logs the status of each.
*/
export async function bootstrapModules(): Promise<ModuleManager> {
const manager = new ModuleManager();
const moduleFiles = fs
.readdirSync(MODULES_DIR)
.filter((fileName) => fileName.endsWith(".module.ts") || fileName.endsWith(".module.js"));
for (const fileName of moduleFiles) {
const filePath = path.join(MODULES_DIR, fileName);
const imported = await import(filePath);
// Each module file must have a default export implementing AppModule
const module = imported.default as AppModule;
if (!module.isEnabled) {
console.log(`[Bootstrap] Skipping disabled module: ${module.name}`);
continue;
}
console.log(`[Bootstrap] Registering module: ${module.name}`);
manager.register(module);
}
await manager.initAll();
return manager;
}
Each module file now exports a default instance and sets its own isEnabled flag:
// modules/analytics.module.ts
import type { AppModule } from "@/lib/module";
class AnalyticsModule implements AppModule {
readonly name = "analytics";
readonly isEnabled = process.env.ANALYTICS_ENABLED === "true";
async init(): Promise<void> {
// set up analytics tracking
}
}
export default new AnalyticsModule();
// modules/email.module.ts
import type { AppModule } from "@/lib/module";
import { EmailService } from "./email.service";
class EmailModule implements AppModule {
readonly name = "email";
readonly isEnabled = true; // always on
private service!: EmailService;
async init(): Promise<void> {
this.service = new EmailService(process.env.EMAIL_API_KEY!);
await this.service.connect();
}
async destroy(): Promise<void> {
await this.service.disconnect();
}
}
export default new EmailModule();
The bootstrap call in app.ts becomes a single line:
// app.ts
import { bootstrapModules } from "./lib/bootstrap";
const manager = await bootstrapModules();
The startup log will now look like this:
[Bootstrap] Skipping disabled module: analytics
[Bootstrap] Registering module: email
[Bootstrap] Registering module: payments
[Bootstrap] Registering module: webhooks
[ModuleManager] Initialising: email
[ModuleManager] Initialising: payments
[ModuleManager] Initialising: webhooks
The key improvements over the manual approach:
| Manual bootstrap | Auto-discovery | |
|---|---|---|
| Adding a module | Import it + register it in app.ts | Drop the file in the modules folder |
| Forgetting to register | Silent — module never loads | Impossible — fs finds all files |
| Disabling a module | Edit app.ts or set env var | Set isEnabled = false on the module |
| Startup visibility | No logging by default | Every module's status is logged |
Why This Matters
| Approach | Disabling a feature | Risk |
|---|---|---|
| Commenting out code | Edit source files | High — easy to break imports |
Feature flags in if blocks |
Scattered conditions everywhere | Medium — hard to track |
| Module Manager | Set an env variable | None |
The module manager also makes it easier to:
- Test in isolation — only register the module under test
- Progressive rollout — enable a module per environment (
stagingonly) - Hot-reload — call
destroytheniniton a single module without restarting the process
7. Comments: JSDoc and When to Write Them
Comments are often misunderstood. Used poorly they create noise. Used well they add the one layer that types and clean naming alone cannot provide: the why.
There are two kinds of comments worth writing, and one kind to always delete.
Role 1: JSDoc — Document Every Public Surface
In TypeScript and JavaScript, every exported class, function, method, type, and interface should have a JSDoc block above it. Even though TypeScript's type system communicates a lot, JSDoc adds the crucial layer that types cannot: what the thing does, what its parameters mean, and what it returns in plain language.
This is especially valuable because IDEs surface JSDoc on hover — a developer can understand a function without ever opening its file.
/**
* Manages the lifecycle of registered application modules.
* Modules can be selectively disabled via the constructor
* without modifying any source code.
*/
export class ModuleManager {
/**
* Creates a new ModuleManager.
* @param disabledModules - Names of modules to skip during registration.
* Typically sourced from an environment variable.
*/
constructor(disabledModules: string[] = []) {
this.disabled = new Set(disabledModules);
}
/**
* Registers a module with the manager.
* If the module's name is in the disabled list it is silently ignored.
* Returns `this` to allow method chaining.
*/
register(module: AppModule): this { ... }
/**
* Initialises all registered modules in insertion order.
* A module that fails to initialise logs its error and does not
* halt the initialisation of subsequent modules.
*/
async initAll(): Promise<void> { ... }
/**
* Destroys all registered modules in reverse insertion order,
* ensuring dependents are torn down before their dependencies.
*/
async destroyAll(): Promise<void> { ... }
/**
* Returns a registered module by name, or `undefined` if not found.
*/
get(name: string): AppModule | undefined { ... }
}
The same applies to types and interfaces — they often form the most-read contracts in a codebase:
/**
* Represents a self-contained unit of application functionality
* that can be registered with the ModuleManager.
*/
export interface AppModule {
/** Unique identifier used for logging and the disabled list. */
readonly name: string;
/** Called once during application startup. Should be idempotent. */
init(): Promise<void>;
/**
* Called during graceful shutdown.
* Optional — only implement if cleanup is required.
*/
destroy?(): Promise<void>;
}
A developer reading this interface understands the entire contract without opening a single implementation file.
A note on TypeScript: Even though TypeScript already enforces the shape of your types, JSDoc is not redundant — it documents the intent and constraints that types cannot express. init(): Promise<void> tells you the signature. Should be idempotent tells you how to use it correctly.
Role 3: Inline Comments — Only When the Code Cannot Speak for Itself
Inline comments have one legitimate use: explaining why something is done in a way that the code itself cannot communicate.
They are not for explaining what the code does. If a comment just restates the next line, delete it.
// ❌ Narration — the code already says this
// Increment the counter
counter++;
// Check if the user is an admin
if (user.role === "admin") { ... }
// ✅ Intent — explains a constraint the code cannot express
// Destroy in reverse order so dependents are torn down before dependencies
for (const module of [...this.modules.values()].reverse()) { ... }
// Skip inactive users per GDPR soft-delete policy (see RFC-44)
if (!user.isActive) continue;
// Pad to 16 bytes — AES-128 block size requirement
const paddedKey = key.padEnd(16, "0");
The test is simple: if a new developer read only the comment and not the line, would they learn something they could not have inferred from the code alone? If yes, keep it. If no, delete it.
The Comment Rules at a Glance
| Type | Purpose | When to write |
|---|---|---|
| JSDoc block | Documents the contract of a public surface | Every exported class, function, method, type, interface |
| Inline why-comment | Explains a non-obvious constraint or trade-off | Only when the code cannot express the reason itself |
| Narration comment | Describes what the next line does | Never — delete on sight |
8. Prefer .catch() Over try/catch Where Possible
This might be a hot take — but try/catch is overused, and in most async JavaScript and TypeScript code, .catch() is the cleaner choice.
Here is why.
try/catch Casts Too Wide a Net
The fundamental problem with try/catch is that it silently catches everything inside the block — including errors you did not intend to catch, like typos, null reference crashes, and logic bugs that have nothing to do with the operation you were guarding.
// ❌ try/catch — catches everything inside, including things that shouldn't fail
async function syncInvoices(companyId: string): Promise<void> {
try {
const invoices = await fetchInvoicesFromApi(companyId);
const formatted = invoices.map(formatInvoice); // bug here? caught silently
const filtered = formatted.filter(isValidInvoice); // bug here? caught silently
await database.invoices.insertMany(filtered);
} catch (error) {
console.error("Sync failed:", error);
}
}
If formatInvoice has a bug that throws a TypeError, this catch block swallows it and logs "Sync failed" — giving you no hint that the real problem is in your transformation logic, not the API call. The error is hidden.
.catch() Is Surgical
.catch() attaches directly to the promise that can actually fail. Everything else runs normally and throws as expected:
// ✅ .catch() — only the API call is guarded; logic errors surface normally
async function syncInvoices(companyId: string): Promise<void> {
const invoices = await fetchInvoicesFromApi(companyId)
.catch((error) => {
console.error("Failed to fetch invoices:", error);
return [];
});
if (invoices.length === 0) return;
const formatted = invoices.map(formatInvoice);
const filtered = formatted.filter(isValidInvoice);
await database.invoices.insertMany(filtered)
.catch((error) => console.error("Failed to persist invoices:", error));
}
Now if formatInvoice throws a bug, it is not caught — it surfaces immediately as an unhandled error, which is exactly what you want. The .catch() blocks only handle the two operations that can legitimately fail due to external reasons: the API call and the database write.
.catch() Eliminates the Nesting
try/catch forces an indentation level. In a function that guards multiple operations, this nesting compounds quickly:
// ❌ try/catch — nested, harder to follow the happy path
async function processOrder(orderId: string): Promise<void> {
try {
const order = await db.orders.findById(orderId);
try {
await chargeCard(order.paymentMethodId, order.totalAmountCents);
try {
await db.orders.update(orderId, { status: "paid" });
await sendConfirmationEmail(order.customerEmail);
} catch (dbError) {
console.error("Failed to update order status:", dbError);
}
} catch (paymentError) {
console.error("Payment failed:", paymentError);
}
} catch (fetchError) {
console.error("Order not found:", fetchError);
}
}
// ✅ .catch() — flat, reads top to bottom, each failure is localised
async function processOrder(orderId: string): Promise<void> {
const order = await db.orders.findById(orderId)
.catch((error) => { console.error("Order not found:", error); return null; });
if (!order) return;
const charged = await chargeCard(order.paymentMethodId, order.totalAmountCents)
.catch((error) => { console.error("Payment failed:", error); return false; });
if (!charged) return;
await db.orders.update(orderId, { status: "paid" })
.catch((error) => console.error("Failed to update order status:", error));
await sendConfirmationEmail(order.customerEmail);
}
Same logic. Zero nesting. Each failure is handled exactly where it occurs.
When try/catch Is Still the Right Choice
.catch() works on promises. try/catch is still appropriate when:
- You are calling synchronous code that can throw — JSON.parse, file system ops in sync mode, third-party SDKs that throw synchronously
- You need to catch errors from multiple awaits and handle them identically in one place
- You are writing a top-level error boundary — the outermost handler where anything uncaught should be logged and the process kept alive
// ✅ try/catch is appropriate here — synchronous, one unified handler needed
function parseConfigFile(raw: string): Config {
try {
return JSON.parse(raw) as Config;
} catch {
throw new Error("Config file contains invalid JSON");
}
}
The rule of thumb: use .catch() when you know exactly which async operation can fail and want to handle it in isolation. Use try/catch when you need a broader safety net around synchronous throws or a top-level boundary.
9. AI-Generated Code: What to Watch Out For
AI coding assistants are genuinely useful tools. But there is something important you need to understand about how they were trained — and what that means for the code they produce.
Why AI Defaults to Bad Nesting
Large language models are trained on publicly available open-source code. While there is excellent code on the internet, the vast majority of it was written without enforcing clean-code principles. Deeply nested if blocks, else chains, multi-line switch arms, and scattered logic are extremely common in the training data — which means they are what the model has learned to produce by default.
This is not a flaw in the AI. It is a direct reflection of how most code is written in the wild.
// ❌ Typical AI-generated output — works, but hard to maintain
async function createUser(data: CreateUserDto) {
if (data.email) {
const existing = await db.users.findByEmail(data.email);
if (!existing) {
if (data.password.length >= 8) {
const hashed = await bcrypt.hash(data.password, 10);
if (hashed) {
const user = await db.users.create({
email: data.email,
password: hashed,
});
if (user) {
await sendWelcomeEmail(user.email);
return { success: true, user };
} else {
return { success: false, error: "Failed to create user" };
}
} else {
return { success: false, error: "Hashing failed" };
}
} else {
return { success: false, error: "Password too short" };
}
} else {
return { success: false, error: "Email already in use" };
}
} else {
return { success: false, error: "Email is required" };
}
}
Five levels deep. The actual success path is buried at the centre. This is the kind of code that gets committed every day by developers who trust AI output without reviewing it.
The cleaned-up version, applying everything in this post:
// ✅ Reviewed and refactored — readable, testable, maintainable
async function createUser(data: CreateUserDto) {
if (!data.email) return { success: false, error: "Email is required" };
if (data.password.length < 8) return { success: false, error: "Password too short" };
const existing = await db.users.findByEmail(data.email);
if (existing) return { success: false, error: "Email already in use" };
const user = await registerNewUser(data);
await sendWelcomeEmail(user.email);
return { success: true, user };
}
async function registerNewUser(data: CreateUserDto) {
const hashedPassword = await bcrypt.hash(data.password, 10);
return db.users.create({ email: data.email, password: hashedPassword });
}
Same logic. Half the lines. Zero nesting beyond one level.
AI and Comments
Another common gap in AI-generated code is the absence of meaningful comments — or the presence of unhelpful ones that simply narrate the code:
// ❌ Narration comments — these add zero value
// Get the user
const user = await getUser(id);
// Check if the user is active
if (!user.isActive) return;
// Send the email
await sendEmail(user.email);
If a comment just describes what the next line does, it is noise. The code already says what it does. What comments should explain is why — the intent, the trade-off, the non-obvious constraint:
// ✅ Intent comments — explain the why, not the what
const user = await getUser(id);
// Inactive users are soft-deleted and should not receive communications
// per GDPR compliance requirements (see RFC-112)
if (!user.isActive) return;
await sendEmail(user.email);
AI tools rarely produce comments like this because they are almost never in training data. That context lives in your head and your team's discussions — you need to add it yourself.
10. Writing Code With AI the Right Way
AI is an extremely capable tool. The key word is tool. A hammer does not decide what to build — you do. The same principle applies here.
The Right Mental Model
You are the engineer. AI is your pair programmer. It writes the first draft. You are responsible for everything it produces.
This is not a philosophical nicety — it is a practical reality. When a bug reaches production, no one asks the AI to fix it. You own the code that ships.
Step 1: Scaffold, Don't Generate
Use AI to produce a structural scaffold — the skeleton of a solution — then fill in the logic yourself or guide the AI iteratively. A good scaffold gives you:
- The function signatures and return types
- The correct path outlined in pseudocode
- The module structure and file layout
// Good AI prompt for scaffolding:
"Create a TypeScript function called processRefund that:
- Takes a refundRequest object with fields: orderId (string), amount (number), reason (string)
- Uses guard clauses, no else statements
- Returns a typed Result object with either success data or an error message
- Does NOT implement the database logic yet — use placeholder comments
- Follows clean code principles with low nesting"
The more constraints you give the AI upfront, the less cleanup you do afterward.
Step 2: Be Explicit About Standards
AI will follow the rules you give it in the prompt. If you do not specify your standards, it will use its training defaults — which, as we established, skew toward nested and uncommented code.
// ❌ Vague prompt — you will get whatever the model defaults to
"Write a function to validate a user registration form"
// ✅ Explicit prompt — you get code aligned with your standards
"Write a TypeScript function to validate a user registration form.
Rules:
- Use guard clauses — no else statements
- Maximum one level of nesting
- Extract any complex checks into their own named helper functions
- Add a comment above any non-obvious validation explaining why it exists
- Return a typed ValidationResult with a list of field errors"
The difference in output quality between these two prompts is significant.
Step 3: Always Review the Output
Treat every AI-generated function as a pull request from a junior developer. You would not merge a PR without reading it. Do not commit AI code without reading it.
Specifically, look for:
| Problem | What to check |
|---|---|
| Deep nesting | Does any block exceed 2 levels? Extract it. |
else after return |
Delete every one you find. |
Multi-line switch arms |
Prefer guard clauses (max 1 nesting level) or an object map. |
| Narration comments | Remove them — they add noise, not value. |
| Missing intent comments | Add them where the why is not obvious. |
| Magic numbers/strings | Extract to named constants. |
| Mixed abstraction levels | Split orchestration from implementation. |
Step 4: Test the Logic, Not Just the Output
AI can produce code that looks correct and runs once but fails in edge cases it was not prompted to consider. Ask it explicitly:
"What edge cases does this function not handle?
List them, then update the code to handle each one with guard clauses."
This forces the model to reason about its own output and surface gaps before they become bugs.
Step 5: You Are Responsible
This point deserves to stand on its own.
You are responsible for every line of code that ships — regardless of who or what wrote it.
AI does not get paged at 2am when the production system is down. It does not sit in the post-mortem. It does not explain to the client why data was lost. You do.
This is not a reason to avoid AI tools — they are genuinely powerful. It is a reason to use them with the same rigour you would apply to any other tool: understand what they produce, verify it, own it.
AI is the tool that you use. Not the other way around.
Putting It All Together
Here is a complete before/after applying every principle from this post — guard clauses, extracted functions, descriptive naming, units, .catch(), and JSDoc all at once.
// ❌ Before — deeply nested, no comments, abbreviated names, broad try/catch
async function handle(req: any): Promise<any> {
try {
const b = await req.json();
if (b) {
if (b.type) {
if (b.type === "payment.success") {
const o = await db.orders.findById(b.oid);
if (o) {
if (!o.paid) {
await db.orders.update(b.oid, { isPaid: true });
await sendEmail(o.email, "Your order is confirmed!");
return new Response("OK", { status: 200 });
} else {
return new Response("Already processed", { status: 200 });
}
} else {
return new Response("Order not found", { status: 404 });
}
} else {
return new Response("Ignored", { status: 200 });
}
} else {
return new Response("Missing type", { status: 400 });
}
} else {
return new Response("Invalid body", { status: 400 });
}
} catch (e) {
return new Response("Error", { status: 500 });
}
}
// ✅ After — every principle applied
/** The expected shape of an incoming webhook payload. */
interface WebhookPayload {
type: string;
orderId: string;
}
/**
* Handles incoming webhook events from the payment provider.
* Only "payment.success" events trigger order processing — all others
* are acknowledged and ignored to avoid reprocessing noise.
*/
async function handleWebhookEvent(request: Request): Promise<Response> {
const payload = await request.json().catch(() => null) as WebhookPayload | null;
if (!payload) return new Response("Invalid body", { status: 400 });
if (!payload.type) return new Response("Missing type", { status: 400 });
if (payload.type !== "payment.success") return new Response("Ignored", { status: 200 });
return handlePaymentSuccess(payload.orderId);
}
/**
* Marks an order as paid and sends a confirmation email to the customer.
* Idempotent — returns 200 without side effects if already paid.
*/
async function handlePaymentSuccess(orderId: string): Promise<Response> {
const order = await db.orders.findById(orderId)
.catch(() => null);
if (!order) return new Response("Order not found", { status: 404 });
if (order.isPaid) return new Response("Already processed", { status: 200 });
await markOrderPaid(order);
return new Response("OK", { status: 200 });
}
/**
* Persists the paid status and dispatches the confirmation email.
* Database write is attempted first — email is only sent on success.
*/
async function markOrderPaid(order: Order): Promise<void> {
await db.orders
.update(order.id, { isPaid: true })
.catch((error) => console.error(`Failed to mark order ${order.id} as paid:`, error));
await sendConfirmationEmail(order.customerEmail);
}
/**
* Sends a payment confirmation email to the given address.
* Failures are logged but do not throw — a paid order must not
* roll back because of an email service outage.
*/
async function sendConfirmationEmail(customerEmail: string): Promise<void> {
await sendEmail(customerEmail, "Your order is confirmed!")
.catch((error) => console.error(`Failed to send confirmation to ${customerEmail}:`, error));
}
The final version:
- Has zero
elseblocks and zero multi-lineswitcharms - Has a maximum nesting depth of one
- Uses full descriptive names —
requestnotreq,payloadnotb,orderIdnotoid - Handles errors with
.catch()surgically — only on the operations that can legitimately fail externally - Has a JSDoc block above every function explaining what it does and any non-obvious constraints
- Can be read top to bottom like a story by a developer who has never seen it before
How to Use These Standards Now
Reading about clean code is one thing. Having your AI assistant actually follow these rules in every project, from the first line of every file, is another.
Every major AI code editor supports a way to give the AI a persistent set of instructions that apply globally — across every project, every chat, every suggestion. Paste your standards in once and the AI will have them in mind for every request you make from that point forward.
Below is a ready-to-copy rule block summarising every principle from this post. Pick your editor, follow the instructions, and paste it in.
Important: These rules will guide the AI's output, but they do not replace your review. The AI will still make mistakes and miss edge cases — your job as the engineer does not change. These rules simply reduce the cleanup you have to do.
The Rules Block
Copy everything inside the code block below:
# Clean Code Standards
## Guard Clauses
- Always use guard clauses. Return or throw early when a condition is not met.
- Never use else statements. Once a return, throw, or continue is reached, the else branch is unreachable — remove it.
- Prefer guard clauses over switch statements. Switch is acceptable when every case is a single-line return, but guard clauses are preferred because they cap nesting at one level regardless of branch complexity. For large sets of mappings (5+ cases), use an object map (lookup table) instead of either.
## Nesting
- Maximum nesting depth is two levels. If a block exceeds this, extract it into a named function.
- Apply guard clauses inside loops: use continue instead of wrapping the loop body in an if block.
## Naming
- Never abbreviate variable, function, class, or type names. Write ctx as context, req as request, err as error, cb as callback, msg as message, btn as button, idx as index, tmp as temp (but prefer a descriptive name), and data as the specific thing it represents (userData, invoicePayload, apiResponse).
- Every name must be self-documenting. A developer must be able to understand what it represents without any surrounding context.
- Boolean names must always be a yes/no question: isActive, hasError, isCacheEnabled, shouldRetry.
- Always include the unit in names for time and measurement values: timeoutMilliseconds, delaySeconds, maxFileSizeBytes, pollingIntervalSeconds.
- Class names must include a role suffix: UserService, PaymentRepository, InvoiceSyncWorker, UserController.
- Never use magic numbers or strings. Extract them to named constants.
## Numeric Literals
- Any numeric literal with more than four digits must use underscore separators grouped in threes from the right: 1_000_000 not 1000000, 5_242_880 not 5242880.
## Functions
- Each function must operate at a single level of abstraction — it either orchestrates high-level steps or handles low-level details, never both.
- Extract any logic that is more than two levels deep into its own named function with a descriptive name.
## Error Handling
- Prefer .catch() over try/catch for async operations. Attach .catch() directly to the promise that can fail so only that operation is guarded. Do not wrap unrelated logic inside a try block.
- Use try/catch only for synchronous throws, when multiple awaits need identical error handling, or at a top-level error boundary.
## Comments
- Add a JSDoc block above every exported class, function, method, type, and interface. Document what it does, what its parameters mean, and any non-obvious constraints or side effects.
- Only write inline comments to explain WHY something is done, never WHAT. If a comment restates what the next line does, delete it.
## Modularity
- Organise large systems into self-contained modules that implement a consistent interface (name, isEnabled, init, destroy).
- Each module controls its own enabled state via an isEnabled property.
- Use a ModuleManager to register, initialise, and destroy modules. Use .catch() on each init and destroy call so a single failing module does not halt the system.
- Auto-discover modules using fs to scan the modules directory rather than manually importing and registering each one.
## Code Style
- Keep all files focused and small. If a file is growing large, split it into focused modules.
- Write code for the developer who reads it next, not for the machine that runs it.
Cursor
Cursor supports global AI rules that apply to every project automatically.
- Open Cursor
- Go to Settings (top-right gear icon, or
Ctrl+Shift+J) - Click Cursor Settings
- Navigate to Rules for AI
- Paste the rules block into the text field
- Click Save
Every chat and inline edit in Cursor will now follow these rules by default across all your projects.
You can also add project-specific rules by creating a file at .cursor/rules/clean-code.mdc inside any repository. Project rules take priority over global ones, so you can override or extend the global standards per project.
GitHub Copilot (VS Code)
Copilot supports repository-level instructions via a markdown file.
- In your project root, create the file
.github/copilot-instructions.md - Paste the rules block into that file
- Save and commit it
Copilot will include these instructions in every suggestion it makes within that repository. Repeat this for each project you want the standards applied to.
For global instructions across all repositories in VS Code:
- Open VS Code Settings (
Ctrl+,) - Search for
github.copilot.chat.codeGeneration.instructions - Click Edit in settings.json
- Add the following:
"github.copilot.chat.codeGeneration.instructions": [
{
"text": "<paste the rules block here as a single string>"
}
]
Windsurf
Windsurf supports a global rules file and per-project rules.
Global rules:
- Open Windsurf
- Go to Settings → AI → Global Rules (or open the Windsurf command palette and search for "global rules")
- Paste the rules block and save
Per-project rules:
- Create a
.windsurfrulesfile in the root of your project - Paste the rules block into it
- Windsurf will automatically apply it for any AI interaction in that project
A Note on What These Rules Actually Do
Setting these rules does not guarantee perfect output. The AI will still occasionally produce deeply nested code, skip JSDoc blocks, or use try/catch where .catch() would be better.
What the rules do is shift the AI's defaults significantly in the right direction. Instead of producing messy code you have to rewrite from scratch, it produces code that is mostly clean and needs a targeted review rather than a full refactor.
Think of it as hiring a developer who has read the same style guide you have. They will not always follow it perfectly, but they will follow it far more often than someone who has never seen it — and when they miss something, it is a quick correction rather than a complete overhaul.
You still review every line. You still own everything that ships. But the baseline quality of what you are reviewing is significantly higher.
Summary
| Principle | Rule |
|---|---|
| Guard clauses | Check for failure first, return immediately |
No else after return |
It's always redundant — delete it |
Prefer guard clauses over switch |
Guard clauses cap nesting at 1 level; switch is fine only for single-line returns |
| Low nesting | If depth > 2, extract a function |
| One abstraction level per function | Orchestrate or implement — not both |
| Named constants | No magic numbers or strings |
| Boolean naming | Always a yes/no question (isActive, hasError) |
| No abbreviations | context not ctx, request not req, error not err |
| Include units in names | timeoutMilliseconds, delaySeconds, maxFileSizeBytes |
| Descriptive class names | Suffix reveals the role: UserService, PaymentRepository |
| The self-documenting test | Can someone read the name in isolation and know exactly what it is? |
| Module Manager | Register, enable, disable — without touching code |
| JSDoc blocks | Document every exported class, function, method, type, and interface |
| Inline comments | Only for the why — never for the what |
| .catch() over try/catch | Attach error handling to only the operation that can fail |
| AI output | Always review — it defaults to the bad habits in its training data |
| AI prompting | Be explicit: specify guard clauses, nesting limits, comment style |
| AI ownership | You own every line that ships — AI is your tool, not your replacement |
These aren't arbitrary rules. Each one exists because it reduces the cognitive load required to understand code — which makes bugs easier to spot, features easier to add, and the codebase more enjoyable to work in.
Write code for the person who reads it. That person is almost certainly future you.
And when you use AI to help you write that code — read every line it gives you. You are the engineer. Act like one.