clean-code-typescript

repository·main·Indexed 27 days ago

https://github.com/labs42io/clean-code-typescript

A collection of clean code principles adapted for TypeScript development. Based on Robert C. Martin's Clean Code, this guide provides guidelines for producing readable, reusable, and refactorable software, covering topics such as meaningful naming, function design, avoiding side effects, and leveraging TypeScript-specific features like enums, access modifiers, and generators.

Tokens
9.2K
Snippets
40
Records
47
Agent score
45%

What's inside clean-code-typescript

  1. Overview of clean-code-typescript

    main
    clean-code-typescript is a guide for producing readable, reusable, and refactorable software in TypeScript. It adapts software engineering principles from Robert C. Martin's Clean Code specifically for TypeScript developers. It is intended as a set of guidelines to assess code quality rather than a strict style guide.
  2. Organize import statements

    main

    Follow these rules to keep dependencies clear and readable:

    1. Alphabetize and Group: Alphabetize import statements and group them by type.
    2. Remove Unused: Delete unused imports.
    3. Alphabetize Named Imports: e.g., import { A, B, C } from 'foo';.
    4. Alphabetize Sources: Alphabetize within groups (e.g., import * as a from 'a'; import * as b from 'b';).
    5. Use import type: Use import type instead of import when only importing types to avoid runtime dependency cycles.
    6. Delineate Groups: Use blank lines to separate groups.

    Required Group Order:

    • Polyfills (e.g., import 'reflect-metadata';)
    • Node builtin modules (e.g., import fs from 'fs';)
    • External modules (e.g., import { query } from 'itiriri';)
    • Internal modules (e.g., import { UserService } from 'src/services/userService';)
    • Modules from a parent directory (e.g., import foo from '../foo';)
    • Modules from the same or sibling directory (e.g., import bar from './bar';)
  3. Apply the Single Responsibility Principle (SRP)

    main

    Ensure a class has only one reason to change by making it conceptually cohesive. Avoid 'jam-packing' a class with multiple functionalities. If a class handles multiple responsibilities (e.g., managing user settings AND verifying credentials), split them into separate classes. This minimizes the impact on dependent modules when a change is required.

    // Good: Separate concerns into UserAuth and UserSettings
    class UserAuth {
      constructor(private readonly user: User) {}
      verifyCredentials() { /* ... */ }
    }
    
    class UserSettings {
      private readonly auth: UserAuth;
    
      constructor(private readonly user: User) {
        this.auth = new UserAuth(user);
      }
    
      changeSettings(settings: UserSettings) {
        if (this.auth.verifyCredentials()) {
          // ...
        }
      }
    }
  4. Remove duplicate code through abstraction

    main

    Avoid duplicating logic by creating abstractions (classes, union types, or common parent classes) that can handle different but related entities. However, be cautious: if two pieces of code live in different domains, duplication might be preferable to an abstraction that introduces unnecessary dependencies between modules.

    type Employee = Developer | Manager;
    
    function showEmployeeList(employee: Employee[]) {
      employee.forEach((employee) => {
        const expectedSalary = employee.calculateExpectedSalary();
        const experience = employee.getExperience();
        const extra = employee.getExtraDetails();
    
        const data = {
          expectedSalary,
          experience,
          extra,
        };
    
        render(data);
      });
    }
  5. Use descriptive function names

    main

    Function names should explicitly state what they do to avoid ambiguity. Avoid generic names like addToDate if the specific operation (like adding a month) is important for the caller to know.

    function addMonthToDate(date: Date, month: number): Date {
      // ...
    }
    
    const date = new Date();
    addMonthToDate(date, 1);
  6. Use descriptive names for tests

    main

    Test names should reveal their intention so that when a test fails, the name provides the first indication of what went wrong.

    describe('Calendar', () => {
      it('should handle leap year', () => {
        // ...
      });
    
      it('should throw when format is invalid', () => {
        // ...
      });
    });
  7. Use getters and setters for object encapsulation

    main

    Instead of accessing properties directly on objects, use TypeScript's get and set syntax. This encapsulates internal representation, allows for easy validation during assignment, enables lazy loading, and provides a single place to add logging or error handling without changing every accessor in the codebase.

    class BankAccount {
      private accountBalance: number = 0;
    
      get balance(): number {
        return this.accountBalance;
      }
    
      set balance(value: number) {
        if (value < 0) {
          throw new Error('Cannot set negative balance.');
        }
    
        this.accountBalance = value;
      }
    
      // ...
    }
    
    const account = new BankAccount();
    account.balance = 100;
  8. Always throw or reject with Error objects

    main

    When using throw or Promise.reject, always use the Error type. This ensures you get a stack trace and compatibility with try/catch/finally blocks. Avoid throwing strings or plain objects.

    function calculateTotal(items: Item[]): number {
      throw new Error('Not implemented.');
    }
    
    async function get(): Promise<Item[]> {
      throw new Error('Not implemented.');
    }
  9. Prefer immutability with readonly and const assertions

    main

    To prevent unexpected mutations and follow functional programming patterns, use immutability tools in TypeScript:

    1. readonly modifier: Mark individual properties in an interface or class as readonly.
    2. Readonly<T> utility type: Use this to mark all properties of a type as readonly.
    3. ReadonlyArray<T>: Use this for arrays to prevent methods like push() or fill(), while still allowing non-mutating methods like concat() or slice().
    4. as const (Const Assertions): Use this for literal values to make objects and arrays deeply read-only.
    5. readonly in function arguments: Use readonly string[] to ensure a function does not mutate its input array.
    // read-only object
    const config = {
      hello: 'world'
    } as const;
    config.hello = 'world'; // error
    
    // read-only array
    const array  = [ 1, 3, 5 ] as const;
    array[0] = 10; // error
    
    // You can return read-only objects
    function readonlyData(value: number) {
      return { value } as const;
    }
    
    const result = readonlyData(100);
    result.value = 200; // error
  10. Automate TypeScript formatting with ESLint

    main

    To avoid arguments over subjective formatting, use automated tools. For TypeScript, ESLint is recommended for static analysis to improve readability and maintainability.

    Available ready-to-use configurations:

    • eslint-config-airbnb-typescript: Airbnb style guide.
    • eslint-plugin-base-style-config: Essential ESLint rules for JS, TS, and React.
    • eslint-config-prettier: Lint rules for the Prettier code formatter.

    If migrating from TSLint to ESLint, use the tslint-to-eslint-config project.

  11. Use iterators and generators for data streams

    main

    When working with collections that can be treated as a stream, use Generators (function*) and Iterables. This allows for lazy execution, decoupling the consumer from the implementation, and efficient memory usage for large datasets.

    function* fibonacci(): IterableIterator<number> {
      let [a, b] = [0, 1];
      while (true) {
        yield a;
        [a, b] = [b, a + b];
      }
    }
    
    // Usage with a loop
    for (const fib of fibonacci()) {
      // ...
    }
  12. Apply the Interface Segregation Principle (ISP)

    main
    Avoid forcing clients to depend on interfaces they do not use. Instead of creating large, 'fat' interfaces that include methods a client might not need (forcing them to implement empty or error-throwing methods), split the interfaces into smaller, more specific ones. This allows classes to implement only the specific behaviors they actually support.