Good Code Practices

In software development, writing clean, maintainable, and efficient code is important. While working on projects in Typescript over the years, I’ve noticed several things that could be better, which I’ll demonstrate with examples. These examples represent real-life scenarios that developers might encounter regularly.

Good Code Practices

Good code is readable, maintainable, efficient, scalable, and allows easy collaboration.
Let’s examine some common code problems that lead to code lacking in the above, and how to address them.

1. Avoid Magic Numbers

Magic numbers are hard-coded values in code that have no clear meaning. They reduce readability and make maintenance more difficult.

Bad Example:

function calculateArea(width: number, height: number): number {
    if (width < 0 || height < 0) { return 0; } if (width > 1000 || height > 1000) {
        return 1000000;
    }
    return width * height;
}

In this example, the meaning of 1000 and 1000000 is unclear. They could be arbitrary limits or maximum values, but without context, it’s difficult to determine. This lack of clarity can lead to confusion and potential errors when modifying the code in the future.

Good Example:

const MAX_DIMENSION = 1000;
const MAX_AREA = MAX_DIMENSION * MAX_DIMENSION;

function calculateArea(width: number, height: number): number {
    if (width < 0 || height < 0) { return 0; } if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
        return MAX_AREA;
    }
    return width * height;
}

By using named constants, the code becomes more self-explanatory. It’s now clear that there’s a maximum dimension for width and height, and a corresponding maximum area. This approach not only improves readability but also makes it easier to update these values if needed, as they’re defined in one place.

2. Use Meaningful Variable Names

Variable names should be descriptive and convey the purpose or content of the variable. This practice makes code self-documenting and easier to understand.

Bad Example:

function processData(d: number[]): number {
    let r = 0;
    for (let i of d) {
        if (i > 10) {
            r += i;
        }
    }
    return r;
}

In this example, the variable names are too short and don’t convey any meaning. It’s not clear what ‘d’, ‘r’, or ‘i’ represent without carefully reading the code. This can slow down comprehension and make the code more error-prone during maintenance.

Good Example:

function processData(data: number[]): number {
    let sum = 0;
    for (let value of data) {
        if (value > 10) {
            sum += value;
        }
    }
    return sum;
}

Now, it’s much clearer that the function is processing a collection of data, summing values greater than 10. The variable names provide context and make the code’s purpose more obvious. This self-documenting approach reduces the need for additional comments and makes the code easier to understand at a glance.

3. Follow the Single Responsibility Principle

Functions should do one thing and do it well. This principle, part of the SOLID principles of object-oriented design, promotes modularity and makes code easier to test and maintain.

Bad Example:

function processAndSaveUserData(name: string, age: number, email: string): void {
    // Validate data
    if (name === '' || age === 0 || email === '') {
        throw new Error("Invalid user data");
    }

    // Process data
    const processedName = name.toUpperCase();
    const processedAge = age + 1;  // Add one year for some reason

    // Save to database (simulated)
    console.log(`Saving to database: ${processedName}, ${processedAge}, ${email}`);

    // Send confirmation email (simulated)
    console.log(`Sending email to ${email}`);
}

This function is doing too much: it validates data, processes it, saves to a database, and sends an email. This makes it hard to test and maintain. If any part of this process needs to change, the entire function might need modification, increasing the risk of introducing bugs.

Good Example:

function validateUserData(name: string, age: number, email: string): void {
    if (name === '' || age === 0 || email === '') {
        throw new Error("Invalid user data");
    }
}

function processUserData(name: string, age: number): [string, number] {
    return [name.toUpperCase(), age + 1];
}

function saveToDatabase(name: string, age: number, email: string): void {
    console.log(`Saving to database: ${name}, ${age}, ${email}`);
}

function sendConfirmationEmail(email: string): void {
    console.log(`Sending email to ${email}`);
}

function processAndSaveUserData(name: string, age: number, email: string): void {
    validateUserData(name, age, email);
    const [processedName, processedAge] = processUserData(name, age);
    saveToDatabase(processedName, processedAge, email);
    sendConfirmationEmail(email);
}

Now, each function has a single responsibility. This makes the code more modular, easier to test, and simpler to maintain or extend in the future. If the validation logic needs to change, for example, only the validateUserData function needs to be modified, reducing the risk of unintended side effects.

4. Use Early Returns

Early returns can make code more readable by reducing nesting and clearly handling edge cases upfront.

Bad Example:

function processPayment(amount: number, balance: number): string {
    let result = '';
    if (amount > 0) {
        if (balance >= amount) {
            const newBalance = balance - amount;
            result = `Payment processed. New balance: ${newBalance}`;
        } else {
            result = 'Insufficient funds';
        }
    } else {
        result = 'Invalid payment amount';
    }
    return result;
}

This function has multiple levels of nesting, which can make it harder to follow the logic flow. It also uses a temporary variable result to store the return value, which is unnecessary.

Good Example:

function processPayment(amount: number, balance: number): string {
    if (amount <= 0) {
        return 'Invalid payment amount';
    }
    
    if (balance < amount) {
        return 'Insufficient funds';
    }
    
    const newBalance = balance - amount;
    return `Payment processed. New balance: ${newBalance}`;
}

By using early returns, we’ve eliminated the need for nesting and the temporary result variable. The code now clearly handles each condition separately, making it easier to read and maintain. This approach also makes it simpler to add new conditions or modify existing ones without affecting the overall structure of the function.

5. Leverage TypeScript’s Type System

TypeScript’s type system is a powerful tool for catching errors early and making code more self-documenting. Use it to your advantage.

Bad Example:

function updateUser(user: any, updates: any): any {
    for (let key in updates) {
        user[key] = updates[key];
    }
    return user;
}

const user = { name: 'John', age: 30 };
const updates = { age: '31', status: 'active' };
const updatedUser = updateUser(user, updates);

This function uses any types, which essentially bypasses TypeScript’s type checking. It allows any property to be added or modified on the user object, which could lead to runtime errors or unexpected behavior.

Good Example:

interface User {
    name: string;
    age: number;
    status?: 'active' | 'inactive';
}

function updateUser(user: User, updates: Partial): User {
    return { ...user, ...updates };
}

const user: User = { name: 'John', age: 30 };
const updates: Partial = { age: 31, status: 'active' };
const updatedUser = updateUser(user, updates);

In this improved version, we define a User interface to clearly specify the shape of our user objects. We use Partial for the updates, which allows us to provide only some of the user properties. The function now uses the spread operator to create a new object, which is a more idiomatic way to update objects in JavaScript/TypeScript.

This approach leverages TypeScript’s type system to catch potential errors at compile-time. For example, it would prevent us from accidentally passing a string for the age property or adding unexpected properties to the user object.

Conclusion

Good code practices are not just theoretical concepts – they have real, practical benefits in day-to-day development. By avoiding magic numbers, using meaningful variable names, adhering to principles like single responsibility, using early returns, and leveraging TypeScript’s type system, developers can create code that’s not only functional but also maintainable and scalable.

Writing good code is a skill that improves with practice. Regularly reviewing and refactoring code, considering how it could be clearer or more efficient, is a valuable habit. These practices contribute to better code quality, easier collaboration within development teams, and ultimately, more robust and reliable software products.

Remember, the goal is not perfection, but continuous improvement. Even small steps towards better code practices can have a significant positive impact over time on the quality and maintainability of your codebase.

関連記事

カテゴリー:

ブログ

情シス求人

  1. 登録されている記事はございません。
ページ上部へ戻る