Generics
The Deep Insight
Every time you write the same logic for different types, you are doing the compiler's job. Generics shift that burden where it belongs.
Consider what a function really is: a mapping from inputs to outputs. When the transformation doesn't depend on the specific type—only on its structure—forcing a concrete type is an arbitrary restriction. Generics remove that restriction.
This is called parametric polymorphism: code that operates uniformly over all types. Unlike subtype polymorphism (inheritance), which selects different implementations at runtime, parametric polymorphism uses the same implementation for every type. The type becomes a parameter, just like a value.
The payoff: One function. All types. Zero duplication. Full type safety.
The Problem Generics Solve
Without generics, we face a dilemma:
// Option 1: Specific but limited
function firstNumber(arr: number[]): number {
return arr[0];
}
function firstString(arr: string[]): string {
return arr[0];
}
// Repetitive!
// Option 2: Flexible but loses type info
function firstAny(arr: any[]): any {
return arr[0];
}
const num = firstAny([1, 2, 3]); // Type is any, not numberGenerics give us both flexibility AND type safety.
Key insight: The any approach erases information. The generic approach preserves it. This distinction matters more as systems grow.
Generic Functions
// T is a type parameter - a placeholder for any type
function first<T>(arr: T[]): T {
return arr[0];
}
const num = first([1, 2, 3]); // Type is number
const str = first(["a", "b", "c"]); // Type is string
const obj = first([{ id: 1 }]); // Type is { id: number }TypeScript infers the type from the argument. You can also specify it explicitly:
const num = first<number>([1, 2, 3]);Mental model: Think of <T> as a function at the type level. Just as function(x) takes a value and returns a value, first<T> takes a type and returns a specialized function.
Multiple Type Parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p = pair("hello", 42); // Type is [string, number]Generic Interfaces
interface Container<T> {
value: T;
getValue(): T;
}
const stringContainer: Container<string> = {
value: "hello",
getValue() {
return this.value;
}
};
const numberContainer: Container<number> = {
value: 42,
getValue() {
return this.value;
}
};Generic Classes
class Box<T> {
private contents: T;
constructor(initial: T) {
this.contents = initial;
}
get(): T {
return this.contents;
}
set(value: T): void {
this.contents = value;
}
}
const stringBox = new Box("hello");
const value = stringBox.get(); // Type is string
const numberBox = new Box(42);
numberBox.set(100); // OK
numberBox.set("hi"); // Error! Expected numberGeneric Constraints
Sometimes we need to limit what types can be used:
// T must have a length property
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
logLength("hello"); // OK - string has length
logLength([1, 2, 3]); // OK - array has length
logLength({ length: 5 }); // OK - has length property
logLength(42); // Error! number has no lengthThe principle: Constraints express the minimum requirements for your function to work. Request exactly what you need—no more. This is the Interface Segregation Principle in action.
keyof and Type Constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name"); // Type is string
const age = getProperty(user, "age"); // Type is number
const foo = getProperty(user, "foo"); // Error! "foo" not in userPractical Example: API Response Type
interface ApiResponse<T> {
status: number;
data: T;
timestamp: number;
}
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
}
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
return response.json();
}
// Usage - TypeScript knows the exact type of data
const userResponse = await fetchApi<User>("/api/user");
console.log(userResponse.data.name); // TypeScript knows .name exists
const postResponse = await fetchApi<Post>("/api/post");
console.log(postResponse.data.title); // TypeScript knows .title existsGeneric Type Aliases
// A result type for operations that might fail
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { success: false, error: new Error("Division by zero") };
}
return { success: true, value: a / b };
}
const result = divide(10, 2);
if (result.success) {
console.log(result.value); // TypeScript knows .value exists here
} else {
console.log(result.error); // TypeScript knows .error exists here
}Default Type Parameters
interface PaginatedResponse<T, M = { page: number; total: number }> {
data: T[];
meta: M;
}
// Uses default meta type
const users: PaginatedResponse<User> = {
data: [{ id: 1, name: "Alice", email: "a@a.com" }],
meta: { page: 1, total: 100 }
};
// Custom meta type
interface CustomMeta {
cursor: string;
hasMore: boolean;
}
const posts: PaginatedResponse<Post, CustomMeta> = {
data: [{ id: 1, title: "Hello", content: "..." }],
meta: { cursor: "abc", hasMore: true }
};Branded Types for Invariants
Use generics to create distinct types for similar underlying values:
type UserId = string & { __brand: "UserId" };
type PostId = string & { __brand: "PostId" };
function createUserId(id: string): UserId {
return id as UserId;
}
function createPostId(id: string): PostId {
return id as PostId;
}
function fetchUser(id: UserId): Promise<User> { /* ... */ }
function fetchPost(id: PostId): Promise<Post> { /* ... */ }
const userId = createUserId("user-123");
const postId = createPostId("post-456");
fetchUser(userId); // OK
fetchUser(postId); // Error! PostId is not UserIdThis prevents an entire class of bugs at compile time. You physically cannot pass the wrong ID type.
Variance: The Hidden Complexity
Here is where most developers get confused—and where understanding pays dividends for your entire career.
Question: If Dog extends Animal, should Array<Dog> be assignable to Array<Animal>?
Your intuition says yes. Type theory says: it depends on how you use it.
class Animal { name: string = ""; }
class Dog extends Animal { bark() {} }
class Cat extends Animal { meow() {} }
// This seems fine...
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // TypeScript allows this
// But now we can do this:
animals.push(new Cat()); // Oops! We just put a Cat in a Dog array
// dogs[1].bark(); // Runtime crash! Cat has no bark()TypeScript allows this assignment for pragmatic reasons, but it's technically unsound. Java had this exact bug for years.
The three variance modes:
| Variance | Meaning | Safe for |
|---|---|---|
| Covariant | Dog[] → Animal[] | Reading only |
| Contravariant | Animal[] → Dog[] | Writing only |
| Invariant | Neither direction | Reading AND writing |
// Covariant: output positions (return types)
// If Dog extends Animal, Producer<Dog> can be used as Producer<Animal>
type Producer<T> = () => T;
// Contravariant: input positions (parameters)
// If Dog extends Animal, Consumer<Animal> can be used as Consumer<Dog>
type Consumer<T> = (item: T) => void;
// Invariant: both positions
// Processor<Dog> and Processor<Animal> are unrelated
type Processor<T> = (item: T) => T;The rule of thumb:
- If your generic only produces values of type T, it can be covariant
- If your generic only consumes values of type T, it can be contravariant
- If it does both, it must be invariant
This is why ReadonlyArray<Dog> safely extends ReadonlyArray<Animal>—you can only read from it, never write.
When NOT to Use Generics
Generics are powerful. That makes them dangerous. Over-genericizing code is a common trap.
Don't use generics when:
- You only have one concrete type
// Overengineered
function processUser<T extends User>(user: T): T { ... }
// Just write this
function processUser(user: User): User { ... }- The generic serves no purpose
// The T adds nothing—it's always inferred as the literal type
function identity<T>(x: T): T { return x; }
const x = identity(5); // Type is just `5`, not useful
// This is fine for learning, but rarely needed in practice- Readability suffers
// This is unreadable
function process<T, U, V extends keyof T, W extends T[V]>(
obj: T, key: V, transform: (val: W) => U
): Partial<Record<V, U>>
// Simpler is better. Create specific types.Do use generics when:
- Multiple callers will use different types
- You're building reusable utilities (libraries, shared code)
- Type relationships must be preserved across a transformation
The test: If removing the generic parameter forces you to use any or duplicate code, keep it. Otherwise, delete it.
The Theorems for Free Principle
Here's a profound insight from type theory: the more generic a function's type, the fewer things it can do, which means you can reason about it more easily.
function mystery<T>(arr: T[]): T[]What can this function do? It can:
- Return the array unchanged
- Return a subset (filter)
- Return a reordered version (sort, reverse, shuffle)
- Return duplicates
What can it NOT do?
- Create new
Tvalues (it doesn't know whatTis) - Modify the values (it can't call methods on
T) - Return an array of different types
The type signature constrains the implementation. This is called parametricity, and it means generic functions are easier to test and reason about.
Check Your Understanding
What does `extends` do in a generic constraint?
Why use generics instead of `any`?
If Dog extends Animal, and you have a function that both reads and writes to an Array<T>, what variance should it be?
Try It Yourself
Practice generics:
Summary
You learned:
- Parametric polymorphism: One implementation for all types, with full type safety
- Type parameters (
<T>) are type-level functions—they take a type and return a specialized version - Constraints (
extends) express minimum requirements—request exactly what you need - Variance determines type compatibility:
- Covariant: safe for reading (output positions)
- Contravariant: safe for writing (input positions)
- Invariant: required when both reading and writing
- Parametricity: Generic signatures constrain implementations, making code easier to reason about
- Judgment: Use generics when they prevent duplication or preserve type relationships; avoid them when they add complexity without benefit
The career lesson: Generics are not just a TypeScript feature. They exist in Java, C#, Rust, Swift, Haskell, and most modern languages. The concepts—parametric polymorphism, variance, type constraints—transfer directly. Master them once, apply them everywhere.
Next, we will explore advanced TypeScript features.