Clean Code Principles Every Developer Should Know

Photo by Unsplash

Photo by Unsplash
Clean code is the foundation of maintainable software. Every developer has inherited a codebase that felt impossible to navigate — cryptic variable names, functions that do fifteen things at once, and zero tests. Clean code principles give you a shared vocabulary and concrete techniques to write software that your future self (and your team) will thank you for. In this post we walk through the most impactful clean code practices with real TypeScript examples you can apply today.
Names are the primary way we communicate intent in code. A well-named variable, function, or class can eliminate the need for a comment entirely. The goal is to write code that reads like a well-written sentence — anyone on your team should be able to understand what a function does just by reading its name and signature.
Avoid single-letter variables outside of trivial loop counters. Avoid abbreviations that save three characters but cost ten minutes of confusion. Use nouns for variables and classes, and verbs for functions. If you need a comment to explain what a variable holds, the variable name is wrong.
Hard-coded numbers scattered throughout your codebase are a maintenance nightmare. Replace them with named constants that explain what the value represents and why it exists. This makes future changes trivial — you change the constant in one place and every usage updates automatically.
// Bad: cryptic naming
function d(a: number[], x: number): boolean {
for (let i = 0; i < a.length; i++) {
if (a[i] === x) return true;
}
return false;
}
// Good: intention-revealing names
function containsValue(numbers: number[], target: number): boolean {
return numbers.some((n) => n === target);
}
// Bad: magic numbers
if (user.age > 17 && user.memberYears > 2) { /* ... */ }
// Good: named constants
const MINIMUM_ADULT_AGE = 18;
const MINIMUM_MEMBER_YEARS_FOR_DISCOUNT = 2;
if (user.age >= MINIMUM_ADULT_AGE && user.memberYears > MINIMUM_MEMBER_YEARS_FOR_DISCOUNT) {
applyDiscount(user);
}Run ESLint with the 'no-magic-numbers' and 'id-length' rules to catch unclear names and magic numbers automatically during your CI pipeline.
The Single Responsibility Principle applies to functions just as much as to classes. A function should do one thing, do it well, and do it only. The tell-tale sign of a function that does too much is when you struggle to give it a name without using the word 'and'. Small functions are easier to test, easier to name, and easier to reuse.
Break large functions into smaller ones that each have a clear, singular purpose. The calling function becomes a high-level orchestrator that reads almost like English. Each helper function can then be unit tested in isolation, making your test suite far more precise and your debugging sessions much shorter.
// Bad: function that does too many things
function processUserOrder(userId: string, items: CartItem[]): void {
const user = db.findUser(userId);
if (!user) throw new Error("User not found");
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const tax = total * 0.1;
const invoice = { user, items, total, tax };
db.saveOrder(invoice);
emailService.sendConfirmation(user.email, invoice);
analyticsService.track("order_placed", { userId, total });
}
// Good: each function has one responsibility
function calculateOrderTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
function buildInvoice(user: User, items: CartItem[]): Invoice {
const total = calculateOrderTotal(items);
return { user, items, total, tax: total * 0.1 };
}
async function placeOrder(userId: string, items: CartItem[]): Promise<void> {
const user = await findUserOrThrow(userId);
const invoice = buildInvoice(user, items);
await db.saveOrder(invoice);
await emailService.sendConfirmation(user.email, invoice);
analyticsService.track("order_placed", { userId, total: invoice.total });
}Functions with more than three arguments are a smell. When you find yourself passing five parameters, consider whether some of them belong in an object, whether the function is doing too much, or whether some parameters should be extracted to the class level. TypeScript interfaces make parameter objects self-documenting without the overhead of positional guessing.
SOLID is a set of five design principles that make object-oriented software more understandable, flexible, and maintainable. While they originated in the OOP world, they apply equally well to functional TypeScript code. Understanding SOLID helps you recognize when your code is headed toward fragility and gives you tools to steer it back.
The Open/Closed Principle says that a module should be open for extension but closed for modification — you should be able to add new behavior without changing existing, tested code. The Dependency Inversion Principle tells us that high-level modules should not depend on low-level modules; both should depend on abstractions. In TypeScript, interfaces are your primary tool for both.
// SOLID: Open/Closed Principle example
// Bad: must modify existing class to add new discount type
class OrderCalculator {
getDiscount(userType: string): number {
if (userType === "premium") return 0.2;
if (userType === "student") return 0.1;
return 0;
}
}
// Good: open for extension, closed for modification
interface DiscountStrategy {
calculate(orderTotal: number): number;
}
class PremiumDiscount implements DiscountStrategy {
calculate(total: number) { return total * 0.2; }
}
class StudentDiscount implements DiscountStrategy {
calculate(total: number) { return total * 0.1; }
}
class OrderCalculator {
constructor(private discount: DiscountStrategy) {}
getFinalPrice(total: number): number {
return total - this.discount.calculate(total);
}
}The Liskov Substitution Principle means that subclasses must be substitutable for their base types without altering the correctness of the program. If a subclass changes fundamental behavior, you have a design problem, not a code problem. Interface Segregation tells us not to force clients to depend on interfaces they do not use — prefer many small, focused interfaces over one large, catch-all interface.
Over-applying SOLID — creating an interface for everything, splitting every function until it's trivially small — leads to its own form of unmaintainable code: abstraction soup. Apply principles where they reduce pain and increase clarity. When a codebase is small and the team is moving fast, pragmatic simplicity often beats architectural purity.
The best comment is the one you don't need to write because the code already explains itself. Comments that merely restate what the code does ('increment i by 1') add noise. Comments that explain why — business context, constraints, non-obvious performance decisions — add genuine value. Always prefer self-documenting code over explanatory comments.
Good candidates for comments include: explanations of legal constraints, links to external specifications or bug reports, clarification of complex algorithms with references to the relevant paper, and documentation of public APIs with JSDoc. Never commit commented-out code — if you need to retrieve it, that's what version control is for.
TypeScript's type system is your most powerful documentation tool. A function signature with well-named parameter types and a return type tells a reader more than three sentences of prose. Combine precise types with JSDoc @param and @returns annotations on public-facing APIs and you have documentation that's always in sync with the code because TypeScript enforces it.
Use the 'Conventional Comments' format (https://conventionalcomments.org) in code reviews to distinguish nitpicks from blockers, making review feedback clearer and faster to act on.
Refactoring is the disciplined practice of restructuring existing code without changing its observable behavior. It's not a big-bang rewrite — it's a series of small, safe transformations, each verified by your test suite. The key insight is: refactoring is impossible without tests. If you don't have tests, your first job is to write them, then refactor.
The most valuable refactorings are: Extract Function (pull a code block into a named function), Rename Variable (apply what you learned about meaningful names), Replace Conditional with Polymorphism (use SOLID to eliminate cascading if/else chains), and Introduce Parameter Object (replace a long argument list with a typed interface). Each is a small step that leaves the code better than you found it.
Key concepts in this post include SRP, OCP, DIP, refactoring, DRY, and YAGNI.