Skip to lesson

learningtypescript.org / intermediate / 07-generics · lesson 7 of 25

TL;DR

Write reusable, type-safe code with TypeScript generics. Learn generic functions, interfaces, classes, and constraints.

Key concepts

  • TypeScript generics
  • generic functions TypeScript
  • generic constraints
  • TypeScript generic tutorial

Generics

Generics let you write code that works with any type while still being type-safe. Instead of using any, generics preserve type information through your code, catching errors at compile time.

Generic Functions

A generic function uses a type parameter (conventionally T) that gets replaced with an actual type when the function is called.

// Without generics — loses type information
function firstElementAny(arr: any[]): any {
  return arr[0];
}

// With generics — preserves type information
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = firstElement([1, 2, 3]); // type: number | undefined
const str = firstElement(["a", "b", "c"]); // type: string | undefined

console.log(`First number: ${num}`);
console.log(`First string: ${str}`);

// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const nameAge = pair("Alice", 30);
console.log(`Pair: ${nameAge[0]}, ${nameAge[1]}`);

// Generic identity with explicit type
function identity<T>(value: T): T {
  return value;
}

const result = identity<string>("hello");
console.log(`Identity: ${result}`);

Generic Interfaces

Interfaces can also use type parameters to create flexible, reusable contracts.

interface Container<T> {
  value: T;
  getValue(): T;
  setValue(newValue: T): void;
}

class Box<T> implements Container<T> {
  constructor(public value: T) {}

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

  setValue(newValue: T): void {
    this.value = newValue;
  }
}

const numberBox = new Box(42);
console.log(`Number box: ${numberBox.getValue()}`);
numberBox.setValue(100);
console.log(`Updated: ${numberBox.getValue()}`);

const stringBox = new Box("hello");
console.log(`String box: ${stringBox.getValue()}`);

// Generic API response
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

type User = { id: number; name: string };
type Product = { id: number; title: string; price: number };

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice" },
  status: 200,
  message: "OK"
};

const productResponse: ApiResponse<Product> = {
  data: { id: 1, title: "Laptop", price: 999 },
  status: 200,
  message: "OK"
};

console.log(`\nUser: ${userResponse.data.name}`);
console.log(`Product: ${productResponse.data.title} - $${productResponse.data.price}`);

Generic Constraints

Use extends to constrain what types a generic can accept.

// Constrain T to have a length property
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength("hello");
logLength([1, 2, 3]);
logLength({ length: 10, name: "test" });
// logLength(42); // Error: number doesn't have 'length'

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

const person = { name: "Bob", age: 30, city: "NYC" };

console.log(`\nName: ${getProperty(person, "name")}`);
console.log(`Age: ${getProperty(person, "age")}`);
// getProperty(person, "email"); // Error: "email" is not a key of person

// Generic with multiple constraints
interface Identifiable {
  id: number;
}

interface Nameable {
  name: string;
}

function displayEntity<T extends Identifiable & Nameable>(entity: T): string {
  return `#${entity.id}: ${entity.name}`;
}

console.log(displayEntity({ id: 1, name: "Alice", role: "admin" }));
console.log(displayEntity({ id: 2, name: "Bob", age: 25 }));

Default Type Parameters

Generic types can have defaults, just like function parameters.

// Default type parameter
interface Collection<T = string> {
  items: T[];
  add(item: T): void;
  getAll(): T[];
}

class SimpleCollection<T = string> implements Collection<T> {
  items: T[] = [];

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

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

// Uses default type (string)
const names = new SimpleCollection();
names.add("Alice");
names.add("Bob");
console.log(`Names: ${names.getAll().join(", ")}`);

// Explicit type
const scores = new SimpleCollection<number>();
scores.add(95);
scores.add(87);
console.log(`Scores: ${scores.getAll().join(", ")}`);

// Generic utility function
function createArray<T = string>(length: number, value: T): T[] {
  return Array.from({ length }, () => value);
}

const strings = createArray(3, "hi");
const numbers = createArray(4, 0);

console.log(`Strings: ${strings}`);
console.log(`Numbers: ${numbers}`);

Try It Yourself

// Build a type-safe event emitter
type EventHandler<T> = (data: T) => void;

class TypedEventEmitter<Events extends Record<string, unknown>> {
  private handlers = new Map<string, Function[]>();

  on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): void {
    const existing = this.handlers.get(event as string) || [];
    existing.push(handler);
    this.handlers.set(event as string, existing);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    const handlers = this.handlers.get(event as string) || [];
    handlers.forEach(h => (h as EventHandler<Events[K]>)(data));
  }
}

// Define event types
type AppEvents = {
  userLogin: { userId: number; timestamp: Date };
  pageView: { url: string; referrer?: string };
  error: { code: number; message: string };
};

const emitter = new TypedEventEmitter<AppEvents>();

emitter.on("userLogin", (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp.toISOString()}`);
});

emitter.on("pageView", (data) => {
  console.log(`Page viewed: ${data.url}`);
});

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

emitter.emit("userLogin", { userId: 1, timestamp: new Date() });
emitter.emit("pageView", { url: "/dashboard" });
emitter.emit("error", { code: 404, message: "Not found" });

Key Takeaways

  • Generics preserve type information while keeping code reusable
  • Use <T> for type parameters — convention uses single uppercase letters
  • Constraints with extends limit what types are accepted
  • keyof T constrains to valid keys of an object type
  • Default type parameters reduce boilerplate for common use cases
  • Generic interfaces and classes create flexible, type-safe abstractions

Pro Tip: If you find yourself using any to make code flexible, that's usually a sign you should use generics instead. Generics give you the same flexibility while keeping full type safety. Start simple with one type parameter and add constraints only when needed.

Next Steps

As your codebase grows, you'll need to split it across files. Next, you'll learn how to organize TypeScript code with ES modules — named exports, default exports, barrel files, and type-only imports.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.