You are viewing a preview of this lesson. Sign in to start learning
Back to TypeScript

TypeScript Generics

Create reusable, type-safe components that work with any data type while maintaining type integrity.

TypeScript Generics

Master TypeScript generics with free flashcards and spaced repetition practice. This lesson covers generic functions, generic classes, generic constraints, and utility typesβ€”essential concepts for writing reusable, type-safe TypeScript code that scales across your applications.

Welcome to TypeScript Generics πŸ’»

Imagine you're building a storage box, but you don't know yet whether it will hold books, shoes, or electronics. Instead of building three different boxes, wouldn't it be better to build one flexible box that adapts to whatever you put inside? That's exactly what generics do in TypeScriptβ€”they let you write code that works with multiple types while maintaining complete type safety.

Generics are one of TypeScript's most powerful features, enabling you to create reusable components that work across different data types without sacrificing the benefits of static typing. Whether you're building utility functions, data structures, or complex APIs, generics will become an indispensable tool in your TypeScript toolkit.

Core Concepts 🎯

What Are Generics?

Generics are a way to create reusable code components that work with multiple types rather than a single one. They act as type variables that allow you to capture the type information passed to a function, class, or interface, and use that information throughout your code.

Think of generics as type parametersβ€”just like function parameters let you pass values, generic parameters let you pass types.

Basic syntax:

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

Here, <T> is the generic type parameter. T is a convention (short for "Type"), but you can use any valid identifier.

πŸ’‘ Tip: Use descriptive names for generic parameters when working with multiple types: <TKey, TValue> is clearer than <T, U> for a map structure.

Why Use Generics?

Without generics, you'd have three options, all with drawbacks:

Approach Problem Example
Using `any` Loses type safety completely function identity(arg: any): any
Using specific types Requires duplicate code for each type function identityString(arg: string): string, function identityNumber(arg: number): number
Using union types Limited flexibility, complex return types function identity(arg: string | number): string | number

Generics solve all these problems by maintaining type relationships while remaining flexible.

Generic Functions πŸ”§

Generic functions are the foundation of generics in TypeScript. They allow you to write functions that preserve type information through the call.

Example: A generic array wrapper

function wrapInArray<T>(value: T): T[] {
  return [value];
}

const numberArray = wrapInArray(42);        // Type: number[]
const stringArray = wrapInArray("hello");   // Type: string[]
const boolArray = wrapInArray(true);        // Type: boolean[]

TypeScript infers the generic type from the argument you pass, but you can also specify it explicitly:

const result = wrapInArray<string>("explicit");  // Explicitly set T = string

Working with multiple generic parameters:

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

const nameAndAge = pair("Alice", 30);     // Type: [string, number]
const coords = pair(10.5, 20.3);          // Type: [number, number]

Generic Constraints πŸ”’

Sometimes you need to restrict what types can be used with a generic. Generic constraints let you specify that a type parameter must have certain properties or extend a particular type.

Using the extends keyword:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): void {
  console.log(arg.length);  // Safe! We know T has a length property
}

logLength("hello");           // βœ… Works: strings have length
logLength([1, 2, 3]);         // βœ… Works: arrays have length
logLength({ length: 10 });    // βœ… Works: object has length
logLength(42);                // ❌ Error: numbers don't have length

Constraining to specific types:

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

const person = { name: "Bob", age: 25 };
const name = getProperty(person, "name");   // Type: string
const age = getProperty(person, "age");     // Type: number
const invalid = getProperty(person, "xyz"); // ❌ Error: "xyz" not in person

The constraint K extends keyof T ensures that key must be a valid property name of obj.

🧠 Mnemonic: "Extends Ensures Eligibility" - The extends keyword ensures only eligible types can be used.

Generic Classes πŸ—οΈ

Classes can also use generics to create type-safe data structures and containers.

class Box<T> {
  private contents: T;

  constructor(value: T) {
    this.contents = value;
  }

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

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

const numberBox = new Box<number>(123);
const stringBox = new Box<string>("hello");

numberBox.setValue(456);        // βœ… Works
numberBox.setValue("text");     // ❌ Error: string not assignable to number

Real-world example: A generic Stack data structure

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

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

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

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

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

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

Generic Interfaces πŸ“‹

Interfaces can be generic, allowing you to define flexible contracts:

interface Repository<T> {
  getById(id: string): T | undefined;
  getAll(): T[];
  add(item: T): void;
  remove(id: string): boolean;
}

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

class UserRepository implements Repository<User> {
  private users: User[] = [];

  getById(id: string): User | undefined {
    return this.users.find(u => u.id === id);
  }

  getAll(): User[] {
    return [...this.users];
  }

  add(user: User): void {
    this.users.push(user);
  }

  remove(id: string): boolean {
    const index = this.users.findIndex(u => u.id === id);
    if (index !== -1) {
      this.users.splice(index, 1);
      return true;
    }
    return false;
  }
}

Generic Type Aliases πŸ“

Type aliases can also be generic:

type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};

type UserResponse = ApiResponse<User>;
type ProductResponse = ApiResponse<Product[]>;

const response: UserResponse = {
  data: { id: "1", name: "Alice", email: "alice@example.com" },
  status: 200,
  message: "Success"
};

Default Generic Types 🎨

You can provide default values for generic type parameters:

interface Config<T = string> {
  value: T;
  label: string;
}

const config1: Config = { value: "default", label: "Label" };  // T defaults to string
const config2: Config<number> = { value: 42, label: "Number" }; // T explicitly set to number

Built-in Generic Utility Types πŸ› οΈ

TypeScript provides powerful built-in generic utility types:

Utility Type Purpose Example
Partial<T> Makes all properties optional Partial<User> allows any subset of User properties
Required<T> Makes all properties required Required<User> requires all User properties
Readonly<T> Makes all properties readonly Readonly<User> prevents property modification
Pick<T, K> Selects specific properties Pick<User, 'name' | 'email'> creates type with only those properties
Omit<T, K> Excludes specific properties Omit<User, 'password'> creates User without password
Record<K, T> Creates object type with keys K and values T Record<string, number> creates string-to-number map

Example usage:

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

type PartialUser = Partial<User>;           // All properties optional
type PublicUser = Omit<User, "password">;   // User without password
type UserPreview = Pick<User, "id" | "name">; // Only id and name
type StringMap = Record<string, string>;    // { [key: string]: string }

Detailed Examples with Explanations πŸ“š

Example 1: Generic API Fetch Function 🌐

Let's build a type-safe API fetcher using generics:

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

// Usage with different types
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

// TypeScript knows the exact return type!
const posts = await fetchData<Post[]>("https://api.example.com/posts");
const comment = await fetchData<Comment>("https://api.example.com/comments/1");

// Now we have full type safety:
console.log(posts[0].title);     // βœ… TypeScript knows this exists
console.log(comment.email);      // βœ… TypeScript knows this exists

Why this works: The generic parameter T captures the expected response type, and TypeScript ensures all downstream code respects that type. You get autocomplete and compile-time errors if you access non-existent properties.

Example 2: Generic Cache Implementation πŸ—„οΈ

class Cache<T> {
  private store = new Map<string, { value: T; expiry: number }>();

  set(key: string, value: T, ttlSeconds: number = 3600): void {
    const expiry = Date.now() + ttlSeconds * 1000;
    this.store.set(key, { value, expiry });
  }

  get(key: string): T | null {
    const item = this.store.get(key);
    if (!item) return null;

    if (Date.now() > item.expiry) {
      this.store.delete(key);
      return null;
    }

    return item.value;
  }

  has(key: string): boolean {
    const value = this.get(key);
    return value !== null;
  }

  clear(): void {
    this.store.clear();
  }
}

// Use the cache with different types
const userCache = new Cache<User>();
const sessionCache = new Cache<{ token: string; userId: string }>();

userCache.set("user:123", { id: "123", name: "Alice", email: "alice@example.com" });
const user = userCache.get("user:123");  // Type: User | null

sessionCache.set("session:abc", { token: "xyz789", userId: "123" });
const session = sessionCache.get("session:abc");  // Type: { token: string; userId: string } | null

Why this is powerful: One cache implementation works for any data type. Each cache instance is type-safeβ€”you can't accidentally store a User in the sessionCache or vice versa.

Example 3: Generic Event Emitter πŸ“’

type EventMap = Record<string, any>;

class TypedEventEmitter<T extends EventMap> {
  private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};

  on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    const callbacks = this.listeners[event];
    if (callbacks) {
      callbacks.forEach(callback => callback(data));
    }
  }

  off<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
    const callbacks = this.listeners[event];
    if (callbacks) {
      const index = callbacks.indexOf(callback);
      if (index !== -1) {
        callbacks.splice(index, 1);
      }
    }
  }
}

// Define your event types
interface AppEvents {
  userLogin: { userId: string; timestamp: number };
  userLogout: { userId: string };
  dataUpdate: { collection: string; count: number };
}

const emitter = new TypedEventEmitter<AppEvents>();

// Type-safe event listeners!
emitter.on("userLogin", (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
  // TypeScript knows exactly what properties 'data' has
});

emitter.emit("userLogin", { userId: "123", timestamp: Date.now() });
// emitter.emit("userLogin", { wrong: "data" });  // ❌ Error: wrong shape

Why this matters: Traditional event emitters use strings and lose all type safety. This generic version ensures you can only emit valid events with the correct data shape.

Example 4: Generic Form Handler πŸ“

interface FormField<T> {
  value: T;
  error: string | null;
  touched: boolean;
}

class FormState<T extends Record<string, any>> {
  private fields: { [K in keyof T]: FormField<T[K]> };

  constructor(initialValues: T) {
    this.fields = {} as any;
    for (const key in initialValues) {
      this.fields[key] = {
        value: initialValues[key],
        error: null,
        touched: false
      };
    }
  }

  setValue<K extends keyof T>(field: K, value: T[K]): void {
    this.fields[field].value = value;
    this.fields[field].touched = true;
  }

  getValue<K extends keyof T>(field: K): T[K] {
    return this.fields[field].value;
  }

  setError<K extends keyof T>(field: K, error: string | null): void {
    this.fields[field].error = error;
  }

  getError<K extends keyof T>(field: K): string | null {
    return this.fields[field].error;
  }

  getValues(): T {
    const values = {} as T;
    for (const key in this.fields) {
      values[key] = this.fields[key].value;
    }
    return values;
  }
}

// Usage with a specific form shape
interface LoginForm {
  email: string;
  password: string;
  rememberMe: boolean;
}

const loginForm = new FormState<LoginForm>({
  email: "",
  password: "",
  rememberMe: false
});

loginForm.setValue("email", "user@example.com");  // βœ… Type-safe
loginForm.setValue("password", "secret123");      // βœ… Type-safe
loginForm.setValue("rememberMe", true);           // βœ… Type-safe
// loginForm.setValue("wrongField", "value");     // ❌ Error: field doesn't exist
// loginForm.setValue("email", 123);              // ❌ Error: wrong type

const email = loginForm.getValue("email");  // Type: string
const remember = loginForm.getValue("rememberMe");  // Type: boolean

Why this is elegant: The form state is completely type-safe. You can't set wrong field names or wrong value types. TypeScript enforces the contract at compile time.

Common Mistakes ⚠️

1. Forgetting Type Constraints

❌ Wrong:

function printLength<T>(arg: T): void {
  console.log(arg.length);  // Error: Property 'length' does not exist on type 'T'
}

βœ… Right:

function printLength<T extends { length: number }>(arg: T): void {
  console.log(arg.length);  // βœ… Works! T is constrained to have length
}

2. Using any Instead of Generics

❌ Wrong:

function wrapInArray(value: any): any[] {
  return [value];
}
const result = wrapInArray(42);  // Type: any[] (lost type info)

βœ… Right:

function wrapInArray<T>(value: T): T[] {
  return [value];
}
const result = wrapInArray(42);  // Type: number[] (preserved type info)

3. Over-Constraining Generics

❌ Wrong:

function identity<T extends string | number>(arg: T): T {
  return arg;
}
identity(true);  // ❌ Error: boolean not assignable

βœ… Right:

function identity<T>(arg: T): T {
  return arg;
}
identity(true);  // βœ… Works! No unnecessary constraint

Rule of thumb: Only add constraints when you need to access specific properties or methods on the generic type.

4. Incorrect Generic Variance

❌ Wrong:

function merge<T>(obj1: T, obj2: T): T {
  return { ...obj1, ...obj2 };  // Error: Type spread may only create object types
}

βœ… Right:

function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

5. Not Inferring Generic Types Properly

❌ Wrong:

const result = identity<any>(42);  // Defeats the purpose of generics

βœ… Right:

const result = identity(42);  // Let TypeScript infer T as number
// or be explicit only when necessary:
const explicit = identity<number>(42);

6. Confusing Generic Classes and Generic Methods

❌ Wrong:

class Container {
  // This creates a NEW generic parameter, unrelated to anything outside
  getValue<T>(): T {
    return this.value;  // Error: what is this.value?
  }
}

βœ… Right:

class Container<T> {
  constructor(private value: T) {}
  
  getValue(): T {
    return this.value;
  }
}

πŸ’‘ Tip: Generic parameters on classes are available to all methods. Generic parameters on methods are local to that method only.

Key Takeaways 🎯

βœ… Generics enable type-safe reusable code - Write once, use with many types

βœ… Use constraints (extends) when you need specific properties - Don't over-constrain unnecessarily

βœ… Let TypeScript infer types when possible - Explicit type arguments are only needed when inference fails

βœ… Generic classes are great for containers and data structures - Stack, Queue, Cache, Repository patterns

βœ… Generic functions preserve type relationships - Input type flows through to output type

βœ… Utility types leverage generics powerfully - Partial<T>, Pick<T, K>, Record<K, T> etc.

βœ… Avoid anyβ€”use generics instead - Maintain type safety while staying flexible

πŸ€” Did you know? The concept of generics originated in ML (1973) and was popularized by Java (1998) and C# (2000s). TypeScript's generics are heavily inspired by C#'s implementation, which makes sense since both were created by Anders Hejlsberg!

πŸ“š Further Study

  1. TypeScript Handbook - Generics: https://www.typescriptlang.org/docs/handbook/2/generics.html
  2. Advanced TypeScript Generic Patterns: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
  3. TypeScript Deep Dive - Generics: https://basarat.gitbook.io/typescript/type-system/generics

πŸ“‹ Quick Reference Card: TypeScript Generics

Basic Generic Function function fn<T>(arg: T): T { return arg; }
Multiple Type Parameters function pair<T, U>(a: T, b: U): [T, U]
Generic Constraint function fn<T extends HasLength>(arg: T)
Keyof Constraint function get<T, K extends keyof T>(obj: T, key: K)
Generic Class class Box<T> { constructor(private value: T) {} }
Generic Interface interface Repository<T> { getAll(): T[]; }
Generic Type Alias type Response<T> = { data: T; status: number; }
Default Generic Type interface Config<T = string> { value: T; }
Partial Utility Partial<User> - All properties optional
Pick Utility Pick<User, 'id' | 'name'> - Select properties
Omit Utility Omit<User, 'password'> - Exclude properties
Record Utility Record<string, number> - Map type

Practice Questions

Test your understanding with these questions:

Q1: Write a generic function called 'firstOrNull' that takes an array of type T[] and returns the first element or null if the array is empty. The function should maintain type safety.
A: !AI