Files
ISA-Frontend/docs/guidelines/code-style.md
2025-04-28 15:36:03 +00:00

21 KiB

Code Style Guidelines

General Principles

  • Readability First: Write code that is easy to read and understand.
  • Consistency: Follow the same patterns and conventions throughout the codebase.
  • Clean Code: Avoid unnecessary complexity and keep functions small and focused.
  • SOLID Principles: Follow SOLID design principles to create more maintainable, flexible, and scalable code.

SOLID Design Principles

SOLID is an acronym for five design principles that help make software designs more understandable, flexible, and maintainable:

  • Single Responsibility Principle (SRP): A class should have only one reason to change, meaning it should have only one job or responsibility.

    // Good - Each class has a single responsibility
    class UserAuthentication {
      authenticate(username: string, password: string): boolean {
        // Authentication logic
      }
    }
    
    class UserRepository {
      findById(id: string): User {
        // Database access logic
      }
    }
    
    // Bad - Class has multiple responsibilities
    class UserManager {
      authenticate(username: string, password: string): boolean {
        // Authentication logic
      }
    
      findById(id: string): User {
        // Database access logic
      }
    
      sendEmail(user: User, message: string): void {
        // Email sending logic
      }
    }
    
  • Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification.

    // Good - Open for extension
    interface PaymentProcessor {
      processPayment(amount: number): void;
    }
    
    class CreditCardProcessor implements PaymentProcessor {
      processPayment(amount: number): void {
        // Credit card processing logic
      }
    }
    
    class PayPalProcessor implements PaymentProcessor {
      processPayment(amount: number): void {
        // PayPal processing logic
      }
    }
    
    // New payment methods can be added without modifying existing code
    
  • Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of subclasses without affecting the correctness of the program.

    // Good - Derived classes can substitute base class
    class Rectangle {
      constructor(
        protected width: number,
        protected height: number,
      ) {}
    
      setWidth(width: number): void {
        this.width = width;
      }
    
      setHeight(height: number): void {
        this.height = height;
      }
    
      getArea(): number {
        return this.width * this.height;
      }
    }
    
    class Square extends Rectangle {
      constructor(size: number) {
        super(size, size);
      }
    
      // Preserve behavior when overriding methods
      setWidth(width: number): void {
        super.setWidth(width);
        super.setHeight(width);
      }
    
      setHeight(height: number): void {
        super.setWidth(height);
        super.setHeight(height);
      }
    }
    
  • Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.

    // Good - Segregated interfaces
    interface Printable {
      print(): void;
    }
    
    interface Scannable {
      scan(): void;
    }
    
    class AllInOnePrinter implements Printable, Scannable {
      print(): void {
        // Printing logic
      }
    
      scan(): void {
        // Scanning logic
      }
    }
    
    class BasicPrinter implements Printable {
      print(): void {
        // Printing logic
      }
    }
    
    // Bad - Fat interface
    interface OfficeMachine {
      print(): void;
      scan(): void;
      fax(): void;
      staple(): void;
    }
    
    // Classes must implement methods they don't need
    
  • Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.

    // Good - Depending on abstractions
    interface Logger {
      log(message: string): void;
    }
    
    class ConsoleLogger implements Logger {
      log(message: string): void {
        console.log(message);
      }
    }
    
    class FileLogger implements Logger {
      log(message: string): void {
        // File logging logic
      }
    }
    
    class UserService {
      constructor(private logger: Logger) {}
    
      createUser(user: User): void {
        // Create user logic
        this.logger.log(`User created: ${user.name}`);
      }
    }
    
    // The UserService depends on the abstraction (Logger interface)
    // not on concrete implementations
    

Following these principles improves code quality by:

  • Reducing coupling between components
  • Making the system more modular and easier to maintain
  • Facilitating testing and extension
  • Promoting code reuse

Extended Guidelines for Angular and TypeScript

This section extends the core code style principles with Angular-specific and advanced TypeScript best practices to ensure consistency and maintainability in our projects.

Angular Enhancements

  • Change Detection: Use the OnPush strategy by default for better performance.
  • Lifecycle Hooks: Explicitly implement Angular lifecycle interfaces (OnInit, OnDestroy, etc.).
  • Template Management: Keep templates concise and use the async pipe to handle observables.
  • Component Structure: Follow best practices for component modularization to enhance readability and testability.
  • Naming Conventions: Follow Angular's official naming conventions for selectors, files, and component classes.
  • File Organization: Structure files according to features and follow the recommended folder structure.
  • Control Flow: Use modern control flow syntax (@if, @for) instead of structural directives (*ngIf, *ngFor).
  • Signals: Prefer signals over RxJS for simpler state management within components.
  • Standalone by Default: Components and directives are standalone by default. The standalone: true flag is unnecessary. Only specify standalone: false when a component or directive explicitly needs to be part of an NgModule.

TypeScript Enhancements

  • Strict Type Checking: Enable strict mode (strict: true) and avoid excessive use of any.
  • Interfaces vs. Types: Prefer interfaces for object definitions and use type aliases for unions and intersections.
  • Generics: Use meaningful type parameter names and constrain generics when applicable.
  • Documentation: Employ JSDoc comments for functions and generic parameters to improve code clarity.
  • Non-Nullability: Use the non-null assertion operator (!) sparingly and only when you're certain a value cannot be null.
  • Type Guards: Implement custom type guards to handle type narrowing safely.
  • Immutability: Favor immutable data structures and use readonly modifiers when applicable.
  • Exhaustiveness Checking: Use exhaustiveness checking for switch statements handling union types.

TypeScript Guidelines

  • Strict Typing:

    • Enable strict: true in tsconfig.json
    • Avoid any unless absolutely necessary
    • Use unknown instead of any when type is truly unknown
    • Always specify return types for functions
    • Use type inference for variable declarations where types are obvious
    // Good
    const user: User = getUserById('123');
    const items = ['apple', 'banana']; // Type inference is fine here
    
    // Bad
    const user: any = getUserById('123');
    const items: string[] = ['apple', 'banana']; // Unnecessary type annotation
    
  • Interfaces and Types:

    • Prefer interface over type for object definitions
    • Use type for unions, intersections, and mapped types
    • Follow Angular's naming convention: Don't prefix interfaces with 'I' (use ComponentProps not IComponentProps)
    • Extend interfaces instead of repeating properties
    • Use readonly modifiers where appropriate
    // Good
    interface BaseProps {
      readonly id: string;
      name: string;
    }
    
    interface UserProps extends BaseProps {
      email: string;
    }
    
    type ValidationResult = 'success' | 'error' | 'pending';
    
    // Bad
    type UserProps = {
      id: string;
      name: string;
      email: string;
    };
    
  • Enums and Constants:

    • Prefer this order of implementation (from most to least preferred):

      1. const enum for better compile-time performance
      2. Object literals with as const for runtime flexibility
      3. Regular enum only when necessary for runtime access
    • When to use each approach:

      • Use const enum for internal application enumerations that don't need runtime access
      • Use const object as const when values need to be inspected at runtime or exported in an API
      • Use regular enum only when runtime enumeration object access is required
    // Good - const enum (preferred for most cases)
    // Advantages: Tree-shakable, type-safe, disappears at compile time
    export const enum ConstEnumStates {
      NotSet = 'not-set',
      Success = 'success',
    }
    
    // Good - const object with 'as const' assertion
    // Advantages: Runtime accessible, works well with API boundaries
    export const ConstStates = {
      NotSet: 'not-set',
      Success: 'success',
    } as const;
    
    // Types can be extracted from const objects
    type ConstStatesType = (typeof ConstStates)[keyof typeof ConstStates];
    
    // Least preferred - regular enum
    // Only use when you need the enum object at runtime
    export enum States {
      NotSet = 'not-set',
      Success = 'success',
    }
    
    • Use union types as an alternative for simple string literals
    // Alternative approach using union types
    export type StatusType = 'not-set' | 'success';
    
  • Functions and Methods:

    • Always export functions as arrow function expressions (const) instead of function declarations
    • Use arrow functions for callbacks and class methods
    • Explicitly type parameters and return values
    • Keep functions pure when possible
    • Use function overloads for complex type scenarios
    • Document complex functions with JSDoc
    // Good
    /**
     * Fetches a user by ID and transforms it to the required format
     * @param id - The user's unique identifier
     * @param includeDetails - Whether to include additional user details
     */
    export const getUser = (id: string, includeDetails = false): Promise<User> => {
      // ...implementation
    };
    
    // Bad
    export function getUser(id) {
      // ...implementation
    }
    

    // Reason for using arrow function expressions: // 1. More consistent with modern JavaScript practices // 2. Easier to mock in unit tests // 3. Avoids 'this' binding issues

  • Generics:

    • Use meaningful type parameter names (e.g., T for type, K for key)
    • Constrain generic types when possible using extends
    • Document generic parameters using JSDoc

Example:

// Good
interface UserProps {
  id: string;
  name: string;
}

interface AdminProps extends UserProps {
  permissions: string[];
}

const enum UserRole {
  Admin = 'ADMIN',
  User = 'USER',
}

const getUser = <T extends UserProps>(id: string): Promise<T> => {
  // ...implementation
};

// Bad
type User = {
  id: any;
  name: any;
};

function getUser(id) {
  // ...implementation
}

Angular-Specific Guidelines

  • Components:

    • Use OnPush change detection strategy by default
    • Implement lifecycle hooks interfaces explicitly
    • Keep templates small and focused
    • Use async pipe instead of manual subscription management
    // Good
    @Component({
      selector: 'app-user-list',
      changeDetection: ChangeDetectionStrategy.OnPush,
    })
    export class UserListComponent implements OnInit, OnDestroy {
      users$ = this.userService.getUsers().pipe(shareReplay(1));
    }
    
    // Bad
    @Component({
      selector: 'app-user-list',
    })
    export class UserListComponent {
      users: User[] = [];
      subscription: Subscription;
    
      ngOnInit() {
        this.subscription = this.userService
          .getUsers()
          .subscribe((users) => (this.users = users));
      }
    }
    
  • Templates and Control Flow:

    • Use modern control flow syntax (@if, @for, @switch) instead of structural directives (*ngIf, *ngFor, *ngSwitch).
    <!-- Good - Modern control flow syntax -->
    <div>
      @if (user) {
      <h1>Welcome, {{ user.name }}!</h1>
      } @else if (isLoading) {
      <h1>Loading user data...</h1>
      } @else {
      <h1>Please log in</h1>
      }
    
      <ul>
        @for (item of items; track item.id) {
        <li>{{ item.name }}</li>
        } @empty {
        <li>No items available</li>
        }
      </ul>
    
      @switch (userRole) { @case ('admin') {
      <app-admin-dashboard />
      } @case ('manager') {
      <app-manager-dashboard />
      } @default {
      <app-user-dashboard />
      } }
    </div>
    
    <!-- Bad - Old structural directives -->
    <div>
      <h1 *ngIf="user">Welcome, {{ user.name }}!</h1>
      <h1 *ngIf="!user && isLoading">Loading user data...</h1>
      <h1 *ngIf="!user && !isLoading">Please log in</h1>
    
      <ul>
        <li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
        <li *ngIf="!items || items.length === 0">No items available</li>
      </ul>
    
      <app-admin-dashboard *ngIf="userRole === 'admin'"></app-admin-dashboard>
      <app-manager-dashboard
        *ngIf="userRole === 'manager'"
      ></app-manager-dashboard>
      <app-user-dashboard
        *ngIf="userRole !== 'admin' && userRole !== 'manager'"
      ></app-user-dashboard>
    </div>
    
    • When using @for, always specify the track expression to optimize rendering performance:
      • Use a unique identifier property (like id or uuid) when available
      • Only use $index for static collections that never change
      • Avoid using non-unique properties that could result in DOM mismatches
    • Leverage contextual variables in @for blocks:
      • $index - Current item index
      • $first - Boolean indicating if this is the first item
      • $last - Boolean indicating if this is the last item
      • $even - Boolean indicating if this index is even
      • $odd - Boolean indicating if this index is odd
      • $count - Total number of items in the collection
    <!-- Good - Using contextual variables -->
    @for (item of items; track item.id; let i = $index, isLast = $last) {
    <li [class.last-item]="isLast">{{ i + 1 }}. {{ item.name }}</li>
    }
    
    • Use the @empty block with @for to handle empty collections gracefully
    • Store conditional expression results in variables for clearer templates:
    <!-- Good - Storing expression result in variable -->
    @if (user.permissions.canEditSettings; as canEdit) {
    <button [disabled]="!canEdit">Edit Settings</button>
    }
    

Error Logging

Proper error logging is critical for diagnosing issues in production applications. Always use the @isa/core/logging module for logging throughout the application.

Basic Logging Guidelines

  • Always use the appropriate log level for the situation:
    • trace - For fine-grained debugging information
    • debug - For development-time debugging information
    • info - For general runtime information
    • warn - For potential issues that don't interrupt operation
    • error - For errors that affect functionality

Setting Up Logging

Use the logger factory function to create loggers in your components and services:

import { logger } from '@isa/core/logging';

@Component({
  selector: 'app-example',
  templateUrl: './example.component.html',
})
export class ExampleComponent implements OnInit {
  // Create a private logger instance
  #logger = logger();
  
  constructor(private userService: UserService) {}
  
  ngOnInit(): void {
    this.#logger.info('Component initialized');
  }
}

Context-Aware Logging

Always provide relevant context when logging:

// Good - With detailed context
this.#logger.error(
  'Failed to load user data', 
  error, 
  { userId: '123', attempt: 2, source: 'UserProfileComponent' }
);

// Bad - No context for troubleshooting
this.#logger.error('Failed to load user data');

Component and Service Level Context

Use the provideLoggerContext to set component-level context:

@Component({
  selector: 'app-user-profile',
  templateUrl: './user-profile.component.html',
  providers: [
    provideLoggerContext({ 
      component: 'UserProfile',
      section: 'Account'
    })
  ]
})
export class UserProfileComponent {
  #logger = logger();
  
  // All logs from this component will include the context
  updateProfile(): void {
    this.#logger.info('Updating user profile');
    // Log output will include { component: 'UserProfile', section: 'Account' }
  }
}

Error Handling Best Practices

  1. Always log the original Error object:
// Good - Includes the original error with stack trace
try {
  // risky operation
} catch (error) {
  this.#logger.error('Operation failed', error, { context: 'additional data' });
}

// Bad - Loses the stack trace and error details
try {
  // risky operation
} catch (error) {
  this.#logger.error(`Operation failed: ${error}`);
}
  1. Structure error handling with contextual information:
saveData(data: UserData): void {
  this.userService.save(data).pipe(
    catchError((error) => {
      this.#logger.error(
        'Failed to save user data', 
        error,
        {
          userId: data.id,
          dataSize: JSON.stringify(data).length,
          operation: 'saveData'
        }
      );
      
      // Handle the error appropriately
      return throwError(() => new Error('Failed to save data'));
    })
  ).subscribe();
}
  1. Use log levels appropriately:
// Debug information during development
this.#logger.debug('Processing data batch', { batchSize: items.length });

// General information during runtime
this.#logger.info('User logged in', { userId: user.id });

// Potential issues that don't break functionality
this.#logger.warn('API response slow', { responseTime: '2500ms', endpoint: '/users' });

// Errors that affect functionality
this.#logger.error('Payment processing failed', error, { orderId: order.id });
  1. In RxJS operators, use tap for logging:
return this.http.get<User[]>('/api/users').pipe(
  tap({
    next: (users) => this.#logger.info('Users loaded', { count: users.length }),
    error: (error) => this.#logger.error('Failed to load users', error)
  }),
  catchError((error) => {
    this.#logger.error('Error in users API call', error, { retry: true });
    return this.fallbackUserService.getUsers();
  })
);
  1. Log lifecycle events when relevant:
export class ImportantComponent implements OnInit, OnDestroy {
  #logger = logger();
  
  ngOnInit(): void {
    this.#logger.debug('Component initialized');
  }
  
  ngOnDestroy(): void {
    this.#logger.debug('Component destroyed');
  }
}

Performance Considerations

  • Avoid expensive operations in log messages in production:
// Bad - Performs expensive operation even if debug level is disabled
this.#logger.debug('Data state:', JSON.stringify(this.largeDataObject));

// Good - Only performs expensive operation if needed
if (isDevMode()) {
  this.#logger.debug('Data state:', { data: this.largeDataObject });
}
  • Consider log level in production environments:
// In app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    // Other providers...
    provideLogging(
      withLogLevel(isDevMode() ? LogLevel.Debug : LogLevel.Warn),
      withSink(ConsoleLogSink)
    )
  ]
};

Project-Specific Preferences

  • Frameworks: Follow best practices for Nx, Angular, date-fns, Ngrx, RxJs and Zod.

  • Testing: Use Jest with Spectator for unit tests and follow the Arrange-Act-Assert pattern.

  • File Naming:

    • Use kebab-case for filenames (e.g., my-component.ts).

    • Follow a pattern that describes the symbol's feature then its type: feature.type.ts

      // Good examples
      user.service.ts
      auth.guard.ts
      product-list.component.ts
      order.model.ts
      
      // Bad examples
      service-user.ts
      userService.ts
      
  • Comments: Use JSDoc for documenting functions, classes, and modules.

Formatting

  • Indentation: Use 2 spaces for indentation.
  • Line Length: Limit lines to 80 characters where possible.
  • Semicolons: Always use semicolons.
  • Quotes: Use single quotes for strings, except when using template literals.

Linting and Tools

  • Use ESLint with the recommended TypeScript and Nx configurations.
  • Prettier should be used for consistent formatting.

References

  • Angular Style Guide - Official Angular style guide with best practices for Angular development
  • Angular Control Flow - Official Angular documentation on the new control flow syntax (@if, @for, @switch)
  • TypeScript Style Guide - TypeScript community style guide with patterns and practices
  • SOLID Design Principles - Wikipedia article explaining the SOLID principles in object-oriented design
  • Clean Code - Robert C. Martin's seminal book on writing clean, maintainable code