Generic classes in TypeScript let you write a class once and use it safely across many different types. In backend development, the most practical application is the repository pattern: one generic base class that handles CRUD operations for any entity, instead of copying the same methods into a UserRepository, ProductRepository, and OrderRepository.


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 use it for multiple types without duplication.
  • Maintainability: changes to shared logic propagate automatically to all callers.
  • Flexibility: handle different data types and structures without type casting.

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 encapsulate data access logic. A generic repository lets you write that logic once and apply it to any model.

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 a type parameter has specific properties. Use the extends keyword to constrain generics.

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 descriptive type parameter names. TEntity or TModel communicates intent better than a bare T.
  • Don't reach for generics unless they provide clear value. A function that takes any and logs it doesn't need a type parameter.
  • Combine generics with interfaces and intersection types for tighter contracts.
  • Document complex generic signatures. A well-placed comment saves the next developer 20 minutes of reverse engineering.

Common Pitfalls and How to Avoid Them

Type Erasure

Type parameters are not available at runtime. TypeScript compiles to JavaScript and erases all type information, so you cannot use T as a value.

Incorrect Approach:

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

Solution: pass the class constructor 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

Generics add cognitive overhead. Don't use them when a simpler signature is just as safe.

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

Generic classes pay off most clearly in the repository and service layers of a backend application. Write the pattern once, parameterize on entity type, and every new model gets the same battle-tested implementation. The compile-time guarantees mean refactors are safer too: rename a property on User and the compiler tells you everywhere the repository is passing the wrong shape.

Keep constraints tight, keep type parameter names meaningful, and resist the urge to generify code that will only ever have one concrete type.


References