TypeScript

Generics Fundamentals

30 min Lesson 11 of 40

Introduction to Generics

Generics are one of TypeScript's most powerful features, allowing you to create reusable components that work with multiple types while maintaining type safety. Instead of working with specific types, generics let you write code that can adapt to different types specified by the caller.

Why Generics Matter

Without generics, you'd either need to use the any type (losing type safety) or create duplicate code for each type. Generics solve both problems by providing:

  • Type Safety: Compile-time type checking with full IntelliSense support
  • Code Reusability: Write once, use with any type
  • Flexibility: Caller decides the concrete type at usage time
  • Documentation: Generic constraints make type requirements explicit

Generic Functions

The simplest form of generics is a generic function. Let's start with a basic example:

// Without generics - loses type information function identityAny(arg: any): any { return arg; } const result1 = identityAny(42); // Type: any (no type safety) const result2 = identityAny("hello"); // Type: any // With generics - preserves type information function identity<T>(arg: T): T { return arg; } const result3 = identity<number>(42); // Type: number const result4 = identity<string>("hello"); // Type: string // Type inference - TypeScript infers the type const result5 = identity(42); // Type: number (inferred) const result6 = identity("hello"); // Type: string (inferred)
Note: The <T> syntax introduces a type parameter. T is a convention (stands for "Type"), but you can use any valid identifier. The type parameter acts as a placeholder that gets replaced with an actual type when the function is called.

Generic Functions with Arrays

Generics are especially useful when working with collections:

// Generic function that works with arrays function getFirstElement<T>(arr: T[]): T | undefined { return arr[0]; } const firstNumber = getFirstElement([1, 2, 3]); // Type: number | undefined const firstString = getFirstElement(["a", "b"]); // Type: string | undefined // Generic function with multiple parameters function merge<T, U>(obj1: T, obj2: U): T & U { return { ...obj1, ...obj2 }; } const merged = merge({ name: "Alice" }, { age: 30 }); // Type: { name: string } & { age: number } console.log(merged.name); // "Alice" console.log(merged.age); // 30 // Generic function with array operations function map<T, U>(arr: T[], fn: (item: T) => U): U[] { return arr.map(fn); } const numbers = [1, 2, 3]; const strings = map(numbers, (n) => n.toString()); // Type: string[] const doubled = map(numbers, (n) => n * 2); // Type: number[]

Generic Interfaces

You can define generic interfaces to create reusable type contracts:

// Generic interface for a key-value pair interface Pair<K, V> { key: K; value: V; } const stringNumberPair: Pair<string, number> = { key: "age", value: 30 }; const numberBooleanPair: Pair<number, boolean> = { key: 1, value: true }; // Generic interface for API responses interface ApiResponse<T> { data: T; status: number; message: string; } interface User { id: number; name: string; email: string; } const userResponse: ApiResponse<User> = { data: { id: 1, name: "Alice", email: "alice@example.com" }, status: 200, message: "Success" }; const usersResponse: ApiResponse<User[]> = { data: [ { id: 1, name: "Alice", email: "alice@example.com" }, { id: 2, name: "Bob", email: "bob@example.com" } ], status: 200, message: "Success" };

Generic Classes

Classes can also use generics to create reusable data structures:

// 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; } size(): number { return this.items.length; } } // Usage with numbers const numberStack = new Stack<number>(); numberStack.push(1); numberStack.push(2); console.log(numberStack.pop()); // 2 console.log(numberStack.peek()); // 1 // Usage with strings const stringStack = new Stack<string>(); stringStack.push("hello"); stringStack.push("world"); console.log(stringStack.pop()); // "world" // Generic class with multiple type parameters class KeyValueStore<K, V> { private store = new Map<K, V>(); set(key: K, value: V): void { this.store.set(key, value); } get(key: K): V | undefined { return this.store.get(key); } has(key: K): boolean { return this.store.has(key); } delete(key: K): boolean { return this.store.delete(key); } } const cache = new KeyValueStore<string, User>(); cache.set("user1", { id: 1, name: "Alice", email: "alice@example.com" }); const user = cache.get("user1"); // Type: User | undefined

Generic Constraints

Sometimes you need to restrict what types can be used with a generic. Constraints let you specify requirements:

// Constraint: T must have a length property interface HasLength { length: number; } function logLength<T extends HasLength>(arg: T): T { console.log(arg.length); return arg; } logLength("hello"); // OK: string has length logLength([1, 2, 3]); // OK: array has length logLength({ length: 10, value: "test" }); // OK: object has length // logLength(42); // Error: number doesn't have length // Constraint: T must have specific properties interface Identifiable { id: number; } function findById<T extends Identifiable>(items: T[], id: number): T | undefined { return items.find(item => item.id === id); } interface Product extends Identifiable { name: string; price: number; } const products: Product[] = [ { id: 1, name: "Laptop", price: 999 }, { id: 2, name: "Mouse", price: 25 } ]; const product = findById(products, 1); // Type: Product | undefined // Constraint: T must extend another type parameter function copyProperties<T extends U, U>(target: T, source: U): T { return Object.assign(target, source); } const result = copyProperties( { name: "Alice", age: 30 }, { name: "Bob" } ); // result.name is "Bob", result.age is 30
Tip: Use generic constraints to make your code more robust. They provide compile-time guarantees about what operations you can perform on the generic type, preventing runtime errors.

Using keyof with Generics

The keyof operator combined with generics creates powerful type-safe patterns:

// Type-safe property access function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const person = { name: "Alice", age: 30, email: "alice@example.com" }; const name = getProperty(person, "name"); // Type: string const age = getProperty(person, "age"); // Type: number // const invalid = getProperty(person, "invalid"); // Error: invalid key // Type-safe property update function setProperty<T, K extends keyof T>( obj: T, key: K, value: T[K] ): void { obj[key] = value; } setProperty(person, "age", 31); // OK // setProperty(person, "age", "31"); // Error: wrong type // setProperty(person, "invalid", 123); // Error: invalid key // Getting all values of an object type-safely function getValues<T>(obj: T): T[keyof T][] { return Object.keys(obj).map(key => obj[key as keyof T]); } const values = getValues(person); // Type: (string | number)[]

Default Generic Types

You can specify default types for generic parameters:

// Generic with default type interface Container<T = string> { value: T; } const stringContainer: Container = { value: "hello" }; // Uses default (string) const numberContainer: Container<number> = { value: 42 }; // Generic function with default type function createArray<T = number>(length: number, value: T): T[] { return Array(length).fill(value); } const numbers = createArray(3, 0); // Type: number[] const strings = createArray<string>(3, ""); // Type: string[] const defaultArray = createArray(3, 42); // Type: number[] (default) // Multiple generics with defaults interface ApiConfig<T = any, E = Error> { data?: T; error?: E; loading: boolean; } const config1: ApiConfig = { loading: false }; // Uses defaults const config2: ApiConfig<User> = { data: { id: 1, name: "Alice", email: "alice@example.com" }, loading: false }; const config3: ApiConfig<User, CustomError> = { error: new CustomError("Failed"), loading: false }; class CustomError extends Error { code: number; constructor(message: string) { super(message); this.code = 500; } }
Warning: Default generic types must come after required generic types. You cannot have a required type parameter after an optional one. Always place defaults at the end of the generic parameter list.

Generic Type Aliases

Type aliases can also be generic, creating reusable complex types:

// Generic type alias for nullable types type Nullable<T> = T | null; let name: Nullable<string> = "Alice"; name = null; // OK // Generic type alias for async functions type AsyncFunction<T> = () => Promise<T> const fetchUser: AsyncFunction<User> = async () => { const response = await fetch("/api/user"); return response.json(); }; // Generic type alias for array or single value type OneOrMany<T> = T | T[]; function process(value: OneOrMany<number>): void { const values = Array.isArray(value) ? value : [value]; values.forEach(v => console.log(v)); } process(42); // OK process([1, 2, 3]); // OK // Generic type alias with constraints type Dictionary<T extends string | number | symbol = string> = { [key in T]: any; }; const stringDict: Dictionary = { a: 1, b: 2 }; const numberDict: Dictionary<number> = { 1: "a", 2: "b" };
Exercise: Create a generic Result<T, E> type that represents either a success with data of type T or an error of type E. Implement helper functions ok<T>(data: T) and err<E>(error: E) to create Result instances. Then create a function that uses this pattern to safely parse JSON.

Best Practices for Generics

  • Use meaningful names: For simple cases, T is fine. For complex cases, use descriptive names like TData, TError, TKey
  • Keep it simple: Don't over-engineer with too many generic parameters
  • Add constraints: Use extends to document type requirements
  • Leverage inference: Let TypeScript infer types when possible to reduce verbosity
  • Use defaults wisely: Provide sensible defaults for common use cases
  • Document generics: Add JSDoc comments explaining type parameters and constraints

Summary

Generics are a cornerstone of TypeScript's type system, enabling you to write flexible, reusable, and type-safe code. You've learned how to create generic functions, interfaces, classes, and type aliases, apply constraints to restrict generic types, and use default types for common scenarios. Understanding generics is essential for working with modern TypeScript codebases and libraries.