SAE Logo

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.

Learn TypeScript Generics in 13 Minutes

©Web Dev Simplified

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

Generics enable creating reusable components that work with multiple types while maintaining type safety. They act like type parameters for functions, classes, and interfaces, allowing you to write code that adapts to different types without sacrificing TypeScript's compile-time checking. Generics solve the problem of creating flexible, type-safe code without resorting to any types.

Generic Functions

Generic functions use type parameters to create flexible function signatures that work with various types. The type parameter acts as a placeholder that gets replaced with actual types when the function is called. TypeScript can automatically infer the type from usage, reducing the need for explicit type annotations while maintaining full type safety throughout the function.

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

Functions can accept multiple type parameters, enabling complex relationships between different types within the same function. Each type parameter operates independently, allowing functions to work with combinations of unrelated types while maintaining type safety for each parameter. This pattern is common in utility functions that transform or combine different types of data.

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

Generic constraints limit which types can be used as type parameters using the extends keyword. This enables writing generic code that assumes certain properties or methods exist on the type parameter. Constraints provide a balance between flexibility and functionality, allowing generic code to be more specific about required type capabilities.

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

Type parameters can constrain other type parameters, creating sophisticated relationships between multiple generic types. This pattern enables functions that work with object properties, ensuring that property keys actually exist on the specified object type. Such constraints prevent runtime errors by catching invalid property access at compile time while maintaining generic flexibility.

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

Generic interfaces define object shapes that work with any type while maintaining type safety. They create reusable contracts for data structures, API responses, or component props that can adapt to different data types. Generic interfaces eliminate code duplication by allowing the same interface structure to work with various content types.

interface Box<T> {
  value: T;
}

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

Generic Type Aliases

Type aliases can accept type parameters, enabling the creation of flexible type definitions that work across multiple scenarios. They're particularly useful for creating utility types, wrapper types, or simplified names for complex generic expressions. Generic type aliases provide the same flexibility as generic interfaces but with type alias syntax and capabilities.

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

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

Generic Functions in Interfaces

Interfaces can describe function signatures that use generic type parameters, creating contracts for functions that work with multiple types. This pattern is valuable for defining APIs, callback interfaces, or service contracts where the function signature needs to adapt to different data types while maintaining consistent behavior.

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

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

let myIdentity: GenericIdentityFn<number> = identity;

Generic Classes

Generic classes create reusable class templates that work with any type while preserving type safety throughout all methods and properties. They're essential for building data structures, containers, or service classes that need to handle different types of data. Generic classes maintain type consistency across instance creation, method calls, and property access.

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

Generic classes can use constraints to ensure type parameters meet specific requirements, combining flexibility with guaranteed functionality. This pattern enables creating specialized containers or services that work with multiple types but require certain capabilities like methods or properties from each type.

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

Default type parameters provide fallback types when no explicit type is specified, making generic interfaces and classes more convenient to use. They're particularly useful for APIs where most use cases work with a common type but flexibility is needed for special cases. Default parameters reduce boilerplate while maintaining type safety.

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

Mapped types use generics to transform existing types by iterating over their properties and applying transformations. They enable creating utility types that modify all properties of a type in systematic ways, like making them optional, readonly, or changing their types. Mapped types are fundamental to TypeScript's built-in utility 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 use generic parameters with conditional logic to select different types based on type relationships. They enable sophisticated type-level programming where the resulting type depends on the characteristics of the input type. Conditional types are essential for creating advanced utility types and type transformations that adapt based on input type properties.

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 follow naming conventions that communicate their purpose and role within generic constructs. Single letters like T, K, V are traditional for simple cases, while descriptive names improve readability in complex scenarios.

The choice between conventional single letters and descriptive names depends on the complexity and context of the generic implementation.

  • 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

Generics enable creating reusable data structures that maintain type safety across different data types. Collections like stacks, queues, and lists benefit from generics by providing consistent interfaces while adapting to any content type. This pattern eliminates code duplication and ensures type safety throughout data manipulation operations.

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

API service classes use generics to provide type-safe responses for different endpoints and data types. This approach enables strongly typed HTTP clients that know the expected response structure for each API call. Generic API services catch type mismatches at compile time while maintaining flexibility for different data models.

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

Generic state management systems adapt to different application state shapes while maintaining type safety throughout state operations. This pattern enables creating reusable state containers, action handlers, and reducers that work with various data structures while preserving compile-time checking.

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

Avoid using any

Replace any types with proper generics to maintain type safety while achieving flexibility. Generic functions provide better type checking and IntelliSense support compared to any-based alternatives.

// 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;
}

Don't over-constrain

Excessive constraints limit generic flexibility unnecessarily. Balance between providing required functionality and maintaining broad applicability by using appropriate constraint levels.

// 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);
}

Leverage type inference

Allow TypeScript to infer types when possible rather than explicitly specifying them. Type inference reduces verbosity while maintaining full type safety in most common usage scenarios.

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

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

Use appropriate constraints

Ensure generic constraints match actual requirements without being overly restrictive. Well-designed constraints enable necessary functionality while keeping generics broadly applicable.

// 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;
}

Choose meaningful generic type names

Use descriptive type parameter names in complex scenarios to improve code readability and maintainability. Clear naming helps other developers understand generic relationships and usage patterns.

// 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

Repository patterns benefit from generics by providing consistent data access interfaces across different entity types. Generic repositories eliminate code duplication while ensuring type-safe CRUD operations for various data models. This pattern maintains strong typing throughout data access layers.

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

Generic event systems provide type-safe event handling where event names map directly to their expected data structures. This approach prevents runtime errors from incorrect event data while maintaining flexible event-driven architectures. Type safety extends to both event emission and handling.

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

Configuration management systems use generics to provide strongly typed access to configuration values while maintaining flexibility for different configuration schemas. Generic configuration managers catch type errors in configuration access while supporting various application configuration structures.

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.

© 2025, SAE Academy