Generics Deep Dive
๐ถ Generics Deep Dive
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.
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.
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.
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.