Samuel Fajreldines

I am a specialist in the entire JavaScript and TypeScript ecosystem.

I am expert in AI and in creating AI integrated solutions.

I am expert in DevOps and Serverless Architecture

I am expert in PHP and its frameworks.

+55 (51) 99226-5039 samuelfajreldines@gmail.com

Using Generic Classes in TypeScript: A Guide for Backend Development

Introduction

In the ever-evolving world of web development, TypeScript has emerged as a powerful tool for building robust and scalable applications. Its strong typing system enhances JavaScript by adding static types, which help catch errors early in the development process. One of the most powerful features of TypeScript is the ability to use generics, particularly generic classes, which enable you to create reusable components that work with a variety of data types.

In backend development, generic classes can significantly improve the flexibility and maintainability of your codebase. This comprehensive guide will explore how to effectively use generic classes in TypeScript for backend development, helping you write cleaner, more efficient, and type-safe code.


Table of Contents

  1. Understanding Generics in TypeScript
  2. Benefits of Using Generic Classes in Backend Development
  3. Creating Generic Classes
  4. Implementing Generic Repositories
  5. Applying Constraints to Generics
  6. Advanced Generic Class Techniques
  7. Real-World Examples
  8. Best Practices
  9. Common Pitfalls and How to Avoid Them
  10. Conclusion

Understanding Generics in TypeScript

Generics allow you to create reusable components that work with a variety of types rather than a single one. This ensures type safety while enabling flexibility.

Basic Generic Function Example:

function identity<T>(arg: T): T {
  return arg;
}

Here, T is a type parameter that acts as a placeholder for the type that will be provided when the function is called.


Benefits of Using Generic Classes in Backend Development

Using generic classes in backend development offers several advantages:

  • Type Safety: Generics provide compile-time type checking, reducing runtime errors.
  • Reusability: Write code once and reuse it for multiple types without duplication.
  • Maintainability: Easily manage and update code since changes propagate through all usages.
  • Flexibility: Handle different data types and structures seamlessly.

Creating Generic Classes

A generic class allows you to define a class with a placeholder for the type of its properties or methods.

Generic Class Syntax:

class GenericClass<T> {
  value: T;

  constructor(value: T) {
    this.value = value;
  }

  getValue(): T {
    return this.value;
  }
}

Usage Example:

const numberInstance = new GenericClass<number>(42);
console.log(numberInstance.getValue()); // Output: 42

const stringInstance = new GenericClass<string>("Hello, World!");
console.log(stringInstance.getValue()); // Output: Hello, World!

Implementing Generic Repositories

In backend applications, repositories are used to encapsulate data access logic. Using generics, you can create a single repository class that works with different models.

Defining a Generic Repository Interface:

interface IRepository<T> {
  getById(id: string): Promise<T | null>;
  getAll(): Promise<T[]>;
  add(entity: T): Promise<void>;
  update(entity: T): Promise<void>;
  delete(id: string): Promise<void>;
}

Implementing a Generic Repository Class:

class Repository<T> implements IRepository<T> {
  private collection: string;

  constructor(collection: string) {
    this.collection = collection;
  }

  async getById(id: string): Promise<T | null> {
    // Implement data retrieval logic here
    return null;
  }

  async getAll(): Promise<T[]> {
    // Implement data retrieval logic here
    return [];
  }

  async add(entity: T): Promise<void> {
    // Implement data insertion logic here
  }

  async update(entity: T): Promise<void> {
    // Implement data update logic here
  }

  async delete(id: string): Promise<void> {
    // Implement data deletion logic here
  }
}

Using the Generic Repository:

interface User {
  id: string;
  name: string;
  email: string;
}

const userRepository = new Repository<User>("users");
userRepository.add({ id: "1", name: "Alice", email: "alice@example.com" });

Applying Constraints to Generics

Sometimes, you need to ensure that a type parameter meets certain conditions. Type constraints allow you to restrict generics to types that have certain properties or methods.

Using the extends Keyword:

interface Identifiable {
  id: string;
}

class BaseEntityRepository<T extends Identifiable> {
  // Implementation...
}

Now, T must be a type that has an id property of type string.

Example:

class Product implements Identifiable {
  id: string;
  price: number;
}

const productRepository = new BaseEntityRepository<Product>();

Advanced Generic Class Techniques

Using Multiple Type Parameters

You can define multiple type parameters for more complex scenarios.

Example:

class MapEntry<K, V> {
  key: K;
  value: V;

  constructor(key: K, value: V) {
    this.key = key;
    this.value = value;
  }
}

const entry = new MapEntry<number, string>(1, "One");

Default Type Parameters

You can assign default types to type parameters.

Example:

class Response<T = any> {
  data: T;
  status: number;

  constructor(data: T, status: number) {
    this.data = data;
    this.status = status;
  }
}

Real-World Examples

Generic Error Handling

Create a generic error response class to standardize error handling.

class ErrorResponse<T> {
  error: T;
  message: string;

  constructor(error: T, message: string) {
    this.error = error;
    this.message = message;
  }
}

interface ValidationError {
  field: string;
  issue: string;
}

const validationErrorResponse = new ErrorResponse<ValidationError>(
  { field: "email", issue: "Invalid format" },
  "Validation Error"
);

Generic Middleware in Express.js

Implement reusable middleware functions using generics.

function genericMiddleware<T>(req: Request, res: Response, next: NextFunction) {
  // Middleware logic using generic type T
  next();
}

Best Practices

  • Use Meaningful Type Parameter Names: Instead of T, use descriptive names like TEntity, TModel.

  • Keep It Simple: Don't overcomplicate generics; use them when they add clear value.

  • Combine with Other TypeScript Features: Leverage interfaces, type unions, and intersection types with generics for more robust code.

  • Comment Your Code: Provide explanations for complex generic implementations to aid understanding.


Common Pitfalls and How to Avoid Them

Type Erasure

Type parameters are not available at runtime due to TypeScript's type erasure. Avoid relying on type information at runtime within generic classes.

Incorrect Approach:

class Factory<T> {
  create(): T {
    return new T(); // Error: 'T' cannot be used as a value
  }
}

Solution:

Provide the class type as a parameter.

class Factory<T> {
  type: new () => T;

  constructor(type: new () => T) {
    this.type = type;
  }

  create(): T {
    return new this.type();
  }
}

class User {
  name: string;
}

const userFactory = new Factory(User);
const user = userFactory.create();

Overusing Generics

Don't use generics when not necessary, as they can make the code harder to read.

Example of Overuse:

function logValue<T>(value: T): void {
  console.log(value);
}

// Not needed; simpler function suffices
function logValue(value: any): void {
  console.log(value);
}

Conclusion

Generics in TypeScript are a potent tool for creating reusable and maintainable code, especially in backend development. By mastering generic classes, you can build flexible data structures, implement design patterns like repositories and services, and write code that elegantly handles various data types.

Remember to apply constraints thoughtfully, follow best practices, and be cautious of common pitfalls. As you harness the power of generics, your TypeScript backend applications will become more robust, scalable, and efficient.


References



Resume

Experience

  • SecurityScoreCard

    Nov. 2023 - Present

    New York, United States

    Senior Software Engineer

    I joined SecurityScorecard, a leading organization with over 400 employees, as a Senior Full Stack Software Engineer. My role spans across developing new systems, maintaining and refactoring legacy solutions, and ensuring they meet the company's high standards of performance, scalability, and reliability.

    I work across the entire stack, contributing to both frontend and backend development while also collaborating directly on infrastructure-related tasks, leveraging cloud computing technologies to optimize and scale our systems. This broad scope of responsibilities allows me to ensure seamless integration between user-facing applications and underlying systems architecture.

    Additionally, I collaborate closely with diverse teams across the organization, aligning technical implementation with strategic business objectives. Through my work, I aim to deliver innovative and robust solutions that enhance SecurityScorecard's offerings and support its mission to provide world-class cybersecurity insights.

    Technologies Used:

    Node.js Terraform React Typescript AWS Playwright and Cypress