Video

Watch the full video on YouTube:

Generics in Practice

Introduction

In this eleventh chapter of our TypeScript series, we dive deep into generics in practice with three real-world use cases. We’ll create a generic API Response, implement a custom Pick function, and build a typed custom event emitter with advanced static typing.

Generics can be one of the most complex topics to understand at first, but once you master them, they allow you to create reusable, flexible code with a robust type system that catches errors at compile time.

Generic API Response

The first use case is standardizing the format of a backend’s response. We’ll create an interface that receives two generics T and U to separate the result from the metadata.

API Response Interface

export interface ApiResponse<T, U> {
  result: T;
  metadata: U;
}

Here we use two generics because a common practice is to separate the result from the metadata we might send.

Class Implementation

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

export class UserResponseDto implements ApiResponse<User, null> {
  result: User;
  metadata: null;

  constructor(user: User) {
    this.result = user;
    this.metadata = null;
  }
}

Implicit Assignment with Constructor

TypeScript offers a syntax to assign values automatically without redundant code:

export class UserResponseDto implements ApiResponse<User, null> {
  constructor(public result: User, public metadata: null = null) {}
}

This approach is cleaner and avoids code duplication. TypeScript creates the attributes and assigns them automatically.

Custom Pick Function

The second use case is creating a function that extracts specific attributes from an object, similar to the Pick utility type, but on the JavaScript side (runtime).

Function Definition

export function pick<T extends Record<string, unknown>, U extends keyof T>(
  origin: T,
  keys: U[]
): Pick<T, U> {
  const result = {} as Pick<T, U>;

  keys.forEach((key) => {
    result[key] = origin[key];
  });

  return result;
}

Generics Explained

  1. T extends Record<string, unknown>: Constrains T to be a dictionary-like object
  2. U extends keyof T: Constrains U to be valid keys of T
  3. The return type is Pick<T, U>: A new type with only the selected attributes

Usage Example

type User = {
  id: number;
  name: string;
  email: string;
  password: string;
};

const user2: User = {
  id: 1,
  name: "Angel",
  email: "angel@gmail.com",
  password: "password123"
};

const publicUser = pick(user2, ["id", "name", "email"]);

console.log(user2);
// { id: 1, name: "Angel", email: "angel@gmail.com", password: "password123" }

console.log(publicUser);
// { id: 1, name: "Angel", email: "angel@gmail.com" }

The function extracts only the attributes we specify, creating a new object without the password.

Typed Custom Event Emitter

The third use case is more advanced. We’ll create an event emitter with static typing that validates both the event name and the payload’s data type.

Event Map

type EventsMap = {
  "user:created": { id: number; email: string };
  "user:updated": { id: number; name?: string; email?: string };
};

Custom Event Emitter Class

export class CustomEventEmitter<T extends Record<string, unknown>> {
  emit<K extends keyof T>(event: K, input: T[K]) {
    console.log(`Event: ${String(event)}`, JSON.stringify(input, null, 4));
  }
}

Using the Emitter

const emitter = new CustomEventEmitter<EventsMap>();

// TypeScript knows "user:created" requires id and email
emitter.emit("user:created", { id: 1, email: "angel@gmail.com" });

// TypeScript knows "user:updated" requires id, name and email are optional
emitter.emit("user:updated", { id: 2, name: "Angel 2", email: "angel2@gmail.com" });

How the Typing Works

  1. T extends Record<string, unknown>: Constrains the generic to a dictionary
  2. K extends keyof T: Each event is a valid key of the dictionary
  3. input: T[K]: The input type is the type of the value at that key

TypeScript statically validates that each event receives the correct data type. If you try to pass an incorrect type, the compiler throws an error.

Best Practices

  1. Use generics to create reusable interfaces like ApiResponse
  2. Constrain generics with extends to restrict what types can be passed
  3. Leverage implicit constructor assignment to avoid redundant code
  4. Create runtime utility types that complement TypeScript’s built-in ones
  5. Use keyof and typeof to make functions that operate on existing types

Conclusion

Generics in practice allow you to create robust, reusable code. From standardized API Responses to typed event emitters, generics are a fundamental tool for any TypeScript developer who wants to write maintainable and scalable code.

Repository

💻 Repository: https://github.com/radagva/ts-example

Next Steps

🔔 Subscribe to the channel to not miss the next chapters of the TypeScript course!

📺 YouTube Channel

TypeScript Course EP10: Generic TypesTypeScript Guide EP12: Creating Custom Utility Types