Clean Code: A Handbook of Agile Software Craftsmanship(opens in a new tab) by Robert C. Martin is probably the closest a software development book can get towards being a timeless classic. Published in 2008, it emphasizes writing readable, maintainable, and elegant code.
The book covers several key areas of software craftsmanship and is not specific to any particular programming language. Here's what I found most useful when reading it, with some additional tips for working with TypeScript.
1. Meaningful Names
Martin stresses the importance of choosing clear, intention-revealing names for variables, functions, and classes. Well-chosen, expressive names can make code self-explanatory, reduce the need for comments, and reduce the number of things you need to remember.
Finding the perfect expressive name for a variable or function can take a long time, so feel free to rename things if you're hit with inspiration later on. Modern IDEs make this easy, and your teammates won't complain if it's a genuine improvement.
I want to add that it's okay if function names are pretty long, if necessary, and that single-letter
variable names are usually terrible. e
could mean error
, event
, or evaluate
, so it can be confusing for someone (your future self included) reading your code.
For variables
1let d: number; // elapsed time in days
This example uses a single-letter variable name, which doesn't convey any meaning about its purpose or content.
1let elapsedTimeInDays: number;
This improved version indicates the variable's value, making the code readable and self-documenting.
For functions
1function getThem(): number[][] {
2 const list1: number[][] = [];
3 for (const x of theList)
4 if (x[0] === 4)
5 list1.push(x);
6 return list1;
7}
This function uses vague names and doesn't clearly communicate its purpose or the nature of the data it's processing.
1interface Cell {
2 isFlagged(): boolean;
3}
4
5function getFlaggedCells(gameBoard: Cell[]): Cell[] {
6 return gameBoard.filter(cell => cell.isFlagged());
7}
The improved version uses descriptive names and leverages TypeScript's type system and array methods to clearly express the function's intent.
2. Functions
Martin advocates for small, focused functions that do one thing well. He provides guidelines on function length, parameter lists, and the Single Responsibility Principle.
1function payEmployee(e: Employee): void {
2 if (e.isActive()) {
3 const salary = calculateSalary(e);
4 const tax = calculateTax(salary);
5 const net = salary - tax;
6 saveToDB(e, net);
7 generatePayslip(e, net);
8 sendEmail(e, "Your payment has been processed");
9 } else {
10 sendEmail(e, "You are not an active employee");
11 }
12}
This function violates the Single Responsibility Principle by handling multiple concerns: calculation, database operations, document generation, and email notifications.
1function processPayment(employee: Employee): void {
2 if (employee.isActive()) {
3 const netSalary = calculateNetSalary(employee);
4 recordPayment(employee, netSalary);
5 notifyEmployee(employee, netSalary);
6 } else {
7 notifyInactiveEmployee(employee);
8 }
9}
10
11function calculateNetSalary(employee: Employee): number {
12 const grossSalary = calculateGrossSalary(employee);
13 const taxAmount = calculateTax(grossSalary);
14 return grossSalary - taxAmount;
15}
16
17function recordPayment(employee: Employee, netSalary: number): void {
18 saveToDB(employee, netSalary);
19 generatePayslip(employee, netSalary);
20}
21
22function notifyEmployee(employee: Employee, netSalary: number): void {
23 sendEmail(employee.getEmailAddress(), `Your payment of ${netSalary} has been processed`);
24}
25
26function notifyInactive Employee(employee: Employee): void {
27 sendEmail(employee.getEmailAddress(), "You are not an active employee");
28}
This improved version breaks down the colossal function into smaller, more focused functions. Each function has a single responsibility, making the code more modular and easier to maintain.
3. Comments
While not dismissing comments entirely, Martin argues for code that is so clear and expressive that it requires minimal additional explanation. He distinguishes between necessary clarifications and redundant noise.
1// Check if the user is logged in
2if (user.isLoggedIn()) {
3 // ...
4}
This comment is unnecessary as the code clearly expresses what's being checked.
1// IMPORTANT: Do not change the order of these operations.
2// The API expects the data to be sent in this specific sequence,
3// or it will reject the entire batch.
4function processUserData(users: User[]): void {
5 for (const user of users) {
6 sendBasicInfo(user);
7 updatePreferences(user);
8 recordLoginTime(user);
9 notifyConnectedServices(user);
10 }
11}
This imaginary API is probably not very well coded and should be rewritten. However, if it were an external API that you couldn't change, this comment would be justified.
JSDoc Comments
One TypeScript-specific feature I learned from another book, Effective TypeScript, by Dan Vanderkam(opens in a new tab) is using JSDoc comments. You can put these in your types and interfaces, creating helpful popups when filling your variables with data.
1import { StaticImageData } from 'next/image';
2
3export interface Article {
4title: string;
5description: string;
6writer: string;
7
8/** Lowercase, separated with a comma and space
9 * Example; 'react, next.js, front-end'
10 */
11keywords: string;
12
13/** Landscape meta image, PNG exactly 1,200 x 675px */
14featuredImage: StaticImageData;
15
16/**Year-Month-Day: '2024-09-04' */
17date: string;
18}
4. Formatting
This chapter is dated, as modern IDEs like VS Code and powerful plugins like Prettier and ESLint can instantly enforce beautiful formatting. However, it's still an important consideration, as coding is probably 98% reading and 2% writing, and anything you can do to make your code more accessible for other people to understand is undoubtedly a good thing.
5. Error Handling
Martin emphasizes the importance of proper error handling techniques, writing code that gracefully handles exceptions and edge cases.
1function readFile(filename: string): void {
2 try {
3 // Read file
4 } catch (e) {
5 console.log("Error reading file");
6 }
7}
This example catches all errors and logs a generic message, losing important error details and potentially hiding serious issues.
1import { promises as fs } from 'fs';
2
3async function readFile(filename: string): Promise<string> {
4 try {
5 return await fs.readFile(filename, 'utf8');
6 } catch (error) {
7 if (error instanceof Error) {
8 if ('code' in error && error.code === 'ENOENT') {
9 throw new Error(`File not found: ${filename}`);
10 }
11 throw new Error(`Error reading file ${filename}: ${error.message}`);
12 }
13 throw error;
14 }
15}
This improved version handles specific error types, provides more detailed error messages, and correctly propagates errors up the call stack.
6. Testing
Martin strongly advocates Test-Driven Development (TDD) and dedicates several chapters to writing effective unit tests. For TypeScript projects, I love using Vitest, as you can also write your tests in TypeScript with minimal or no additional configuration.
1import { describe, it, expect, beforeEach } from 'vitest';
2
3import { Calculator } from './Calculator';
4
5describe('Calculator', () => {
6 let calc: Calculator;
7
8 beforeEach(() => {
9 calc = new Calculator();
10 });
11
12 it('should add two numbers correctly', () => {
13 expect(calc.add(2, 2)).toBe(4);
14 expect(calc.add(-1, 1)).toBe(0);
15 expect(calc.add(-1, -1)).toBe(-2);
16 });
17});
This test is clear and concise and covers multiple scenarios for the add function, including positive, negative, and zero-sum cases.
7. Problematic Code Patterns
Martin extensively discusses signs of suboptimal code and how to improve it. These include:
a) Rigidity
When software is difficult to change because every modification affects many other parts of the system.
1class Report {
2 generateReport(): void {
3 this.getData();
4 this.formatData();
5 this.printReport();
6 this.emailReport();
7 this.saveToDatabase();
8 }
9 // ... other methods
10}
This class is rigid because any change to the report generation process requires modifying this class, potentially affecting all its functionalities.
1interface Data {}
2interface FormattedData {}
3
4interface DataSource {
5 getData(): Data;
6}
7
8interface Formatter {
9 format(data: Data): FormattedData;
10}
11
12interface Printer {
13 print(data: FormattedData): void;
14}
15
16interface EmailService {
17 send(data: FormattedData): void;
18}
19
20interface DatabaseService {
21 save(data: FormattedData): void;
22}
23
24class Report {
25 constructor(
26 private dataSource: DataSource,
27 private formatter: Formatter,
28 private printer: Printer,
29 private emailService: EmailService,
30 private dbService: DatabaseService
31 ) {}
32
33 generateReport(): void {
34 const data = this.dataSource.getData();
35 const formattedData = this.formatter.format(data);
36 this.printer.print(formattedData);
37 this.emailService.send(formattedData);
38 this.dbService.save(formattedData);
39 }
40}
This version is more flexible as each component can be modified or replaced independently, adhering to the Dependency Inversion Principle.
b) Fragility
Changes in one part of the code unexpectedly break other seemingly unrelated parts.
1class UserService {
2 registerUser(username: string, email: string): void {
3 // Register user
4 this.sendWelcomeEmail(username, email);
5 this.updateUserCount();
6 }
7
8 private sendWelcomeEmail(username: string, email: string): void {
9 // Send email
10 }
11
12 private updateUserCount(): void {
13 // Update count in database
14 }
15}
If the email sending fails, it will prevent the user count from being updated, even though these operations are not logically dependent.
1interface EmailService {
2 sendWelcomeEmail(username: string, email: string): void;
3}
4
5interface UserCountService {
6 incrementUserCount(): void;
7}
8
9class UserService {
10 constructor(
11 private emailService: EmailService,
12 private countService: UserCountService
13 ) {}
14
15 registerUser(username: string, email: string): void {
16 // Register user
17 try {
18 this.emailService.sendWelcomeEmail(username, email);
19 } catch (e) {
20 // Log error, but don't prevent further operations
21 console.error('Failed to send welcome email', e);
22 }
23 this.countService.incrementUserCount();
24 }
25}
This version separates concerns and ensures that a failure in one operation doesn't affect others, making the system more robust.
Conclusion
'Clean Code' remains a pivotal text in software development, offering enduring principles that transcend specific programming languages. Its practical focus and concrete examples provide invaluable guidance for transforming problematic code into clean, efficient solutions. While some may find certain aspects prescriptive, the core concepts of writing clear, maintainable code are more relevant than ever in today's complex software landscape.