TypeScript Generics

Generics are one of the most powerful features in TypeScript, allowing you to create reusable components that work with a variety of types while maintaining full type safety. They provide a way to create flexible, type-safe code without sacrificing compilation checks.

Why Generics?

Before diving into generics, let's understand why they're necessary. Consider this function that returns the input it receives:

Bad any

function identity(arg: any): any {
  return arg;
}

This works but has a significant problem: we lose type information. When we pass in a number, we only know that any type is returned, not specifically a number.

With generics, we can preserve this type information:

Good generics

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

Now when we use this function, TypeScript knows that the return value will be of the same type as the input.

Generic Basics

Generic Functions

The most basic use of generics is in functions:

Basic use of generics

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

// Explicit type parameter
let output1 = identity<string>("myString");
// Type inference - TypeScript infers the type automatically
let output2 = identity("myString");

In this example:

  • <T> declares a type parameter
  • arg: T means the argument is of type T
  • : T specifies that the function returns a value of type T
  • When calling the function, you can either explicitly specify the type or let TypeScript infer it

Multiple Type Parameters

You can use multiple type parameters:

Multiple types

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const pairResult = pair<string, number>("hello", 42);
// pairResult is of type [string, number]

Generic Constraints

Sometimes you need to restrict the types that a generic can use. This is done with the extends keyword:

Generic constraints

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  // Now we know arg has a .length property
  console.log(arg.length);
  return arg;
}

loggingIdentity("hello"); // Works, string has a length property
loggingIdentity([1, 2, 3]); // Works, arrays have a length property
// loggingIdentity(3); // Error: Number doesn't have a length property

Here, T extends Lengthwise means the type parameter must have all the properties of the Lengthwise interface.

Using Type Parameters in Generic Constraints

You can also use a type parameter to constrain another type parameter:

Type parameters

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "John", age: 30 };
console.log(getProperty(person, "name")); // Works
// console.log(getProperty(person, "address")); // Error: "address" is not in type "{ name: string, age: number }"

In this example, the second type parameter K is constrained to be a key of the first type parameter T.

Generic Interfaces and Types

Generic Interfaces

Interfaces can also be generic:

Interface

interface Box<T> {
  value: T;
}

let stringBox: Box<string> = { value: "hello" };
let numberBox: Box<number> = { value: 42 };

Generic Type Aliases

Type aliases can be generic as well:

Type aliases

type Container<T> = { value: T };

let stringContainer: Container<string> = { value: "hello" };

Generic Functions in Interfaces

Interfaces can describe function signatures that use generics:

Functions

interface GenericIdentityFn<T> {
  (arg: T): T;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

Generic Classes

Classes can also use generics:

Classes

class Queue<T> {
  private data: T[] = [];

  push(item: T): void {
    this.data.push(item);
  }

  pop(): T | undefined {
    return this.data.shift();
  }

  peek(): T | undefined {
    return this.data[0];
  }

  get length(): number {
    return this.data.length;
  }
}

const numberQueue = new Queue<number>();
numberQueue.push(10);
numberQueue.push(20);
console.log(numberQueue.pop()); // 10
console.log(numberQueue.peek()); // 20

In this example, we create a Queue class that works with any type. When we instantiate it with new Queue<number>(), all the methods use the correct type.

Generic Class with Constraints

Like functions, classes can also use constraints on their type parameters:

Generic class with constraints

interface Printable {
  print(): void;
}

class PrintableList<T extends Printable> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  printAll(): void {
    this.items.forEach((item) => item.print());
  }
}

class Invoice implements Printable {
  constructor(
    private invoiceNumber: string,
    private amount: number
  ) {}

  print(): void {
    console.log(`Invoice #${this.invoiceNumber}: $${this.amount}`);
  }
}

const invoices = new PrintableList<Invoice>();
invoices.add(new Invoice("INV-001", 250));
invoices.add(new Invoice("INV-002", 500));
invoices.printAll();

In this example, the PrintableList class can only work with types that implement the Printable interface.

Advanced Generic Patterns

Default Type Parameters

TypeScript allows you to specify default types for your generics:

Default types

interface ApiResponse<T = any> {
  data: T;
  status: number;
  statusText: string;
  headers: Record<string, string>;
}

// Using the default
const responseWithDefault: ApiResponse = {
  data: "any data",
  status: 200,
  statusText: "OK",
  headers: { "Content-Type": "application/json" },
};

// Specifying a type
interface User {
  id: number;
  name: string;
}

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "John" },
  status: 200,
  statusText: "OK",
  headers: { "Content-Type": "application/json" },
};

Generic Mapped Types

TypeScript provides powerful ways to transform existing types into new ones:

Generic mapped types

// Make all properties optional
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// Make all properties readonly
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

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

// All properties are optional
const partialUser: Partial<User> = {
  name: "John",
};

// All properties are readonly
const readonlyUser: Readonly<User> = {
  id: 1,
  name: "John",
  email: "john@example.com",
};

// readonlyUser.name = "Jane"; // Error: Cannot assign to 'name' because it is a read-only property

These are actually built-in utility types in TypeScript, but they're implemented using generics.

Conditional Types

Conditional types select a type based on a condition:

Conditional types

type NonNullable<T> = T extends null | undefined ? never : T;

type StringOrNumber = string | number | null | undefined;
type NonNullStringOrNumber = NonNullable<StringOrNumber>; // string | number

Understanding Type Parameters

Type parameters in generics are conventionally represented by single capital letters, but you can use any valid identifier. Here are some common conventions:

  • T: Type (generic)
  • K: Key (often for objects)
  • V: Value (often paired with K)
  • E: Element (often for arrays/collections)
  • P: Property
  • R: Return type
  • S, U: Additional types when more than one type parameter is needed

Using descriptive names for complex generics can make the code more readable:

function merge<TFirst, TSecond>(obj1: TFirst, obj2: TSecond): TFirst & TSecond {
  return { ...obj1, ...obj2 };
}

Practical Use Cases

Generic Data Structures

One of the most common uses of generics is in data structures:

Generic data structure

class Stack<T> {
  private elements: T[] = [];

  push(element: T): void {
    this.elements.push(element);
  }

  pop(): T | undefined {
    return this.elements.pop();
  }

  peek(): T | undefined {
    return this.elements[this.elements.length - 1];
  }

  isEmpty(): boolean {
    return this.elements.length === 0;
  }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2

const stringStack = new Stack<string>();
stringStack.push("hello");
console.log(stringStack.peek()); // "hello"

Generic API Services

Generics are excellent for typing API responses:

Generic API Services

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

interface Product {
  id: number;
  title: string;
  price: number;
}

class ApiService {
  async get<T>(url: string): Promise<T> {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }
    return response.json() as Promise<T>;
  }
}

const api = new ApiService();

async function fetchData() {
  try {
    // TypeScript knows this is a User
    const user = await api.get<User>("/users/1");
    console.log(user.name);

    // TypeScript knows this is a Product
    const product = await api.get<Product>("/products/1");
    console.log(product.title, product.price);
  } catch (error) {
    console.error(error);
  }
}

State Management

Generics are useful in state management, like in a Redux-style store:

Generic state management

interface Action<T = any> {
  type: string;
  payload?: T;
}

class Store<S> {
  private state: S;

  constructor(initialState: S) {
    this.state = initialState;
  }

  getState(): S {
    return this.state;
  }

  dispatch<T>(action: Action<T>, reducer: (state: S, action: Action<T>) => S): void {
    this.state = reducer(this.state, action);
  }
}

interface AppState {
  count: number;
  user: { name: string } | null;
}

const initialState: AppState = {
  count: 0,
  user: null,
};

const store = new Store<AppState>(initialState);

function reducer<T>(state: AppState, action: Action<T>): AppState {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    case "SET_USER":
      return { ...state, user: action.payload as any };
    default:
      return state;
  }
}

store.dispatch({ type: "INCREMENT" }, reducer);
console.log(store.getState().count); // 1

store.dispatch({ type: "SET_USER", payload: { name: "John" } }, reducer);
console.log(store.getState().user); // { name: 'John' }

Common Mistakes and Best Practices

1. Using any instead of generics

// Less type-safe
function getLast(arr: any[]): any {
  return arr[arr.length - 1];
}

// Type-safe with generics
function getLast<T>(arr: T[]): T | undefined {
  return arr.length ? arr[arr.length - 1] : undefined;
}

2. Overconstraining generics

// Too restrictive - only works with objects containing id field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

// More flexible - allows custom comparison
function findBy<T, K extends keyof T>(items: T[], key: K, value: T[K]): T | undefined {
  return items.find((item) => item[key] === value);
}

3. Not leveraging type inference

TypeScript can often infer the correct types:

// Don't do this (explicit types not needed)
const result = identity<string>("hello");

// Do this (let TypeScript infer the type)
const result = identity("hello");

4. Using appropriate constraints

Ensure your generic constraints are appropriate:

// This allows accessing .length on any T
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

// More specific for arrays
function arrayLength<T>(arr: T[]): number {
  return arr.length;
}

5. Use meaningful generic type names

Use descriptive names for clarity in complex scenarios:

// Less readable
function process<T, U, V>(input: T, transform: (item: T) => U, filter: (item: U) => V): V {
  return filter(transform(input));
}

// More readable
function process<TInput, TIntermediate, TOutput>(
  input: TInput,
  transform: (item: TInput) => TIntermediate,
  filter: (item: TIntermediate) => TOutput
): TOutput {
  return filter(transform(input));
}

Real-World Examples

Example 1: Generic Repository Pattern

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: T[] = [];

  findById(id: number): T | undefined {
    return this.items.find((item) => item.id === id);
  }

  findAll(): T[] {
    return [...this.items];
  }

  create(item: Omit<T, "id"> & { id?: number }): T {
    const newItem = {
      ...item,
      id: item.id ?? this.generateId(),
    } as T;

    this.items.push(newItem);
    return newItem;
  }

  update(id: number, item: Partial<Omit<T, "id">>): T | undefined {
    const index = this.items.findIndex((existingItem) => existingItem.id === id);
    if (index === -1) return undefined;

    this.items[index] = { ...this.items[index], ...item };
    return this.items[index];
  }

  delete(id: number): boolean {
    const initialLength = this.items.length;
    this.items = this.items.filter((item) => item.id !== id);
    return initialLength !== this.items.length;
  }

  private generateId(): number {
    return this.items.length ? Math.max(...this.items.map((item) => item.id)) + 1 : 1;
  }
}

// Usage
interface User extends Entity {
  id: number;
  name: string;
  email: string;
}

const userRepo = new Repository<User>();

const user1 = userRepo.create({ name: "Alice", email: "alice@example.com" });
const user2 = userRepo.create({ name: "Bob", email: "bob@example.com" });

console.log(userRepo.findAll()); // [user1, user2]
console.log(userRepo.findById(1)); // user1

const updatedUser = userRepo.update(1, { name: "Alice Smith" });
console.log(updatedUser); // user1 with updated name

userRepo.delete(2);
console.log(userRepo.findAll()); // [user1]

Example 2: Event System with Generics

type EventHandler<T> = (data: T) => void;

class EventEmitter<TEventMap> {
  private handlers: Map<keyof TEventMap, EventHandler<any>[]> = new Map();

  on<K extends keyof TEventMap>(event: K, handler: EventHandler<TEventMap[K]>): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event)!.push(handler);
  }

  off<K extends keyof TEventMap>(event: K, handler: EventHandler<TEventMap[K]>): void {
    if (!this.handlers.has(event)) return;

    const eventHandlers = this.handlers.get(event)!;
    this.handlers.set(
      event,
      eventHandlers.filter((h) => h !== handler)
    );
  }

  emit<K extends keyof TEventMap>(event: K, data: TEventMap[K]): void {
    if (!this.handlers.has(event)) return;

    for (const handler of this.handlers.get(event)!) {
      handler(data);
    }
  }
}

// Usage
interface AppEvents {
  userLoggedIn: { userId: string; timestamp: number };
  dataSaved: { entity: string; id: number };
  error: { message: string; code: number };
}

const events = new EventEmitter<AppEvents>();

const onUserLogin = (data: AppEvents["userLoggedIn"]) => {
  console.log(`User ${data.userId} logged in at ${new Date(data.timestamp).toISOString()}`);
};

events.on("userLoggedIn", onUserLogin);
events.on("error", (data) => {
  console.error(`Error ${data.code}: ${data.message}`);
});

// Type-safe event emitting
events.emit("userLoggedIn", { userId: "user123", timestamp: Date.now() });
events.emit("error", { message: "Something went wrong", code: 500 });

// This would cause a type error:
// events.emit('userLoggedIn', { message: 'Wrong event data' });

Example 3: Type-Safe Configuration System

interface ConfigSchema {
  server: {
    port: number;
    host: string;
  };
  database: {
    url: string;
    timeout: number;
  };
  features: {
    darkMode: boolean;
    betaFeatures: boolean;
  };
}

class ConfigManager<T> {
  private config: Partial<T> = {};

  set<K extends keyof T>(key: K, value: T[K]): void {
    this.config[key] = value;
  }

  get<K extends keyof T>(key: K, defaultValue?: T[K]): T[K] | undefined {
    return this.config[key] ?? defaultValue;
  }

  getSection<K extends keyof T>(sectionKey: K): T[K] | undefined {
    return this.config[sectionKey];
  }

  merge(newConfig: Partial<T>): void {
    this.config = { ...this.config, ...newConfig };
  }

  getAll(): Partial<T> {
    return { ...this.config };
  }
}

// Usage
const config = new ConfigManager<ConfigSchema>();

config.set("server", { port: 3000, host: "localhost" });
config.set("features", { darkMode: true, betaFeatures: false });

// Access with correct types
const serverConfig = config.getSection("server");
if (serverConfig) {
  console.log(`Server running at ${serverConfig.host}:${serverConfig.port}`);
}

// This would cause type errors:
// config.set('server', { port: '3000' }); // Error: string is not assignable to number
// config.set('unknown', {}); // Error: 'unknown' is not a valid key

Exercises

Exercise 1: Create a Generic Pair

Create a generic Pair class that can hold two values of different types.

  • Create a class Pair with two type parameters T and U
  • Add a constructor that accepts two parameters (one of type T and one of type U)
  • Store the parameters as public properties first and second
  • Implement a swap method that returns a new Pair with the values (and their types) swapped
  • Implement a toString method that returns a readable string representation of the pair
  • Test your implementation with different data types

Exercise 2: Implement a Generic Result Type

Create a generic Result type that can represent either a success with a value or an error.

  • Define a type Result<T, E> that can be either a Success<T> or a Failure<E>
  • Implement a Success class with a readonly value property of type T
  • Implement a Failure class with a readonly error property of type E
  • Both classes should have properties indicating whether they are a success or failure
  • Add a map method that applies a function to the success value
  • Add a getOrElse method that returns either the success value or a default value
  • Implement helper functions success and failure to simplify creation
  • Test your type with an example (e.g., a division function)

Exercise 3: Create a Generic Memoize Function

Implement a generic memoize function that caches the results of a function call based on its arguments.

  • Create a function memoize that takes a function as a parameter and returns a new function
  • The function should be generic to work with different function types
  • Use Parameters<T> and ReturnType<T> to extract parameter and return types of the passed function
  • Implement a cache (e.g., using Map) to store results of function calls
  • Use JSON.stringify to use the arguments as keys for the cache
  • Check before each function call if the result is already in the cache
  • Test your implementation with different functions (e.g., a computationally expensive Fibonacci function)

In a real implementation, you'd want a more sophisticated cache key strategy for object arguments.

Summary

TypeScript generics provide powerful tools for creating flexible, reusable, and type-safe code. Key points to remember:

  • Generics allow you to create reusable components that work with a variety of types
  • Type parameters (<T>) act as placeholders for types that will be specified later
  • Constraints (extends) restrict which types can be used with your generics
  • Generics can be used with functions, interfaces, classes, and type aliases
  • TypeScript often infers generic types, reducing verbosity
  • Advanced patterns include mapped types, conditional types, and default type parameters
  • Practical applications include data structures, API services, and state management

By mastering generics, you'll be able to write more maintainable code that strikes the perfect balance between flexibility and type safety.