Generics Deep Dive

TypeScript Intermediate — Generics Deep Dive
TypeScript Intermediate
Course 2 · Chapter 2 · Generics Deep Dive

🔶 Generics Deep Dive

Generics are TypeScript's superpower for writing reusable, type-safe code. You've seen basic generics (`Array`, `Promise`), but true mastery comes from understanding constraints, variance, and how to compose generic types. This chapter teaches you to think in generics.

Generic Constraints: Restricting Type Parameters

By default, a type parameter T can be anything. Constraints let you say "T must satisfy this requirement":

function getProperty<T, K extends keyof T>(obj: T, key: K) { return obj[key]; // ✅ TypeScript knows key exists on T } const user = { name: "Alice", age: 30 }; const result = getProperty(user, "name"); // ✅ "name" is valid // getProperty(user, "email"); // ❌ "email" is not a key of user

Key syntax: K extends keyof T means "K must be a key that exists on T."

Extends a Type

T extends string — T must be a string or a subtype of string.

Extends a Union

T extends string | number — T must be one of these types.

Extends keyof

K extends keyof T — K must be a property name of T.

Multiple Constraints

Chain them: <T extends { length: number }> — T must have a length property.

Example: Pick from an Array with Constraints

// Only works on objects with a specific property type function pickByType<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } interface Config { port: number; host: string; } const config: Config = { port: 3000, host: "localhost" }; const port = pickByType(config, "port"); // ✅ type is number // Type inference knows the exact return type based on which key you pick!

Default Type Parameters

Just like function parameters, type parameters can have defaults:

type Result<T = string> = { success: boolean; data: T; }; type StringResult = Result; // T defaults to string type NumberResult = Result<number>; // T is explicitly number const response: StringResult = { success: true, data: "hello" // No need to specify <string> };

Variance: Covariance & Contravariance

Variance describes how generic types relate when their type parameters are subtypes of each other. This is subtle but crucial:

class Animal {} class Dog extends Animal {} // Arrays are COVARIANT in their type parameter const dogs: Dog[] = [new Dog()]; const animals: Animal[] = dogs; // ✅ Dog[] is assignable to Animal[] // Functions are CONTRAVARIANT in their parameter type const feedAnimal = (a: Animal) => { }; const feedDog = (d: Dog) => { }; const callback: (animal: Animal) => void = feedDog; // ❌ Error! // A function expecting a Dog can't be used where we pass any Animal

Covariance

If Dog extends Animal, then Box<Dog> extends Box<Animal>. Read-only types.

Contravariance

If Dog extends Animal, then Callback<Animal> extends Callback<Dog>. Function parameters.

Invariance

Box<Dog> is not assignable to Box<Animal>. Mutable types.

Why It Matters

Prevents type errors. Contravariance protects function parameter safety.

keyof and typeof: Type Introspection

keyof gets property names; typeof gets the type of a value:

const user = { name: "Alice", age: 30 }; // typeof gets the type of the value type UserType = typeof user; // { name: string; age: number } // keyof gets all property names type UserKeys = keyof UserType; // "name" | "age" // Combine them: create a type that maps keys to their values type UserValues = UserType[keyof UserType]; // string | number // Practical: generic object property getter function createGetter<T, K extends keyof T>(obj: T, key: K) { return () => obj[key]; }

Generic Utility Types: The Toolkit

TypeScript provides built-in generic utilities for common transformations:

interface User { id: number; name: string; email: string; } // Partial: make all properties optional type PartialUser = Partial<User>; // Pick: select specific properties type UserPreview = Pick<User, "name" | "email">; // Omit: exclude specific properties type UserWithoutId = Omit<User, "id">; // Record: map keys to a value type type UserRoles = Record<"admin" | "user" | "guest", User[]>; // Readonly: make all properties readonly type ReadOnlyUser = Readonly<User>; // Extract: types assignable to U from union T type StringOrNumber = string | number | boolean; type Strings = Extract<StringOrNumber, string>; // string // Exclude: opposite of Extract type NonStrings = Exclude<StringOrNumber, string>; // number | boolean

Partial / Required

Toggle optionality. Partial<T> makes all optional; Required<T> makes all required.

Pick / Omit

Slice a type. Pick selects properties; Omit excludes them.

Record

Build a type from keys. Record<K, V> creates an object with keys K, values V.

Extract / Exclude

Filter unions. Extract keeps matching types; Exclude removes them.

Building Your Own Generic Utilities

Chain utilities and constraints to build powerful reusable patterns:

Example: Extracting Function Parameters

// Get all parameter types from a function type Parameters<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never; const add = (a: number, b: number) => a + b; type AddParams = Parameters<typeof add>; // [number, number] // Now you can build around it function callWithLogging<T extends (...args: any[]) => any>( fn: T, ...args: Parameters<T> ) { console.log("Calling with", args); return fn(...args); }

Recursive Generics: Types That Call Themselves

Generics can reference themselves for deeply nested structures:

// Make a type recursive: useful for nested data type NestedArray<T> = T | NestedArray<T>[]; const deep: NestedArray<number> = [ 1, [2, 3], [4, [5, 6]] // ✅ Arbitrary nesting is valid ]; // Practical: make object values deeply partial type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; interface Config { db: { host: string; port: number; ssl: { cert: string; } } } type PartialConfig = DeepPartial<Config>; // All properties at all levels optional

🏗️ Real-World Pattern: Generic API Response Handler

Type-Safe Fetch Wrapper

interface ApiResponse<T> { status: "success" | "error"; data?: T; error?: string; } async function fetchJson<T>(url: string): Promise<T> { const response = await fetch(url); return response.json(); } async function getUser(id: number) { const user = await fetchJson<{ id: number; name: string }>( `/api/users/${id}` ); console.log(user.name); // ✅ TypeScript knows name exists }

💻 Coding Challenges

Challenge 1: Generic with Constraints

Write a function that takes an object and a key, and returns the value. Use constraints to ensure the key exists on the object. Bonus: make the return type exactly match the property type.

Goal: Practice keyof constraints and type inference.

→ Solution

Challenge 2: Build a Utility Type

Create a utility type GetType<T, K> that takes an object type and a key, and returns the type of that property. Test it on a sample interface.

Goal: Combine keyof, conditional types, and generics.

→ Solution

Challenge 3: Recursive Generic

Create a type Flatten<T> that "unwraps" nested arrays. For example, Flatten<[1, [2, 3]]> should be 1 | 2 | 3.

Goal: Build recursive type logic with array handling.

→ Solution

⚠️ Gotcha: Over-Constraining Generics

Constraints are powerful but can make code rigid. Before constraining a type parameter, ask: "Does this really need to be restricted?" Sometimes accepting any (though not ideal) is simpler than a complex constraint. Use constraints to enforce safety, not just to feel thorough.

🎯 What's Next

With generics mastered, we'll explore Decorators & Metadata — a powerful (and experimental) feature for attaching type information and behavior to classes and properties.