TypeScript Generics Complete Guide: From Basics to Advanced Patterns
Introduction
Generics let you write functions, classes, and types that work across multiple types while preserving type relationships. Instead of any or duplicating implementations for string, number, and custom objects, generics parameterize types—Array<T>, Promise<T>, useState<T> are everywhere.
This guide progresses from basic generic functions to constraints, default type parameters, conditional types, infer, and patterns like builder APIs and typed event emitters. These tools are essential for library authors and anyone maintaining shared packages in a monorepo.
Table of Contents
- Generic Functions and Type Parameters
- Generic Interfaces and Classes
- Constraints with extends
- Default and Multiple Type Parameters
- Conditional Types and infer
- Mapped Types and Utility Patterns
- Real-World API Examples
- Best Practices
- Common Mistakes
- Interview Questions
- Summary
- References
Generic Functions and Type Parameters
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // number
const str = identity("hello"); // string
TypeScript infers T from arguments. Explicit annotation when inference fails:
identity<string>("explicit");
Generic arrays:
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // number | undefined
first(["a", "b"]); // string | undefined
Generic Interfaces and Classes
interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
interface User {
id: string;
name: string;
}
type UserResponse = ApiResponse<User>;
Generic class:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
Constraints with extends
Limit what types can be passed:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "1", name: "Monu", age: 30 };
const name = getProperty(user, "name"); // string
// getProperty(user, "invalid"); // Error
Constraint on shape:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
logLength("abc");
logLength([1, 2, 3]);
// logLength(42); // Error: number has no length
Default and Multiple Type Parameters
interface Paginated<T = unknown> {
items: T[];
page: number;
total: number;
}
type StringPage = Paginated<string>;
type DefaultPage = Paginated; // items: unknown[]
Multiple parameters:
function merge<T, U>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = merge({ id: 1 }, { name: "test" });
// { id: number; name: string }
Conditional Types and infer
type IsString<T> = T extends string ? true : false;
type A = IsString<"hi">; // true
type B = IsString<42>; // false
Extract return type manually:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = () => boolean;
type R = ReturnType<Fn>; // boolean
Unpack Promise:
type Awaited<T> = T extends Promise<infer U> ? U : T;
type X = Awaited<Promise<string>>; // string
Mapped Types and Utility Patterns
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
Custom mapped type:
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type UserNullable = Nullable<User>;
Real-World API Examples
Typed fetch wrapper:
async function apiGet<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.json() as Promise<T>;
}
const user = await apiGet<User>("/api/users/1");
Event emitter:
type EventMap = {
login: { userId: string };
logout: { userId: string };
error: { message: string };
};
class TypedEmitter<T extends Record<string, unknown>> {
private listeners: Partial<Record<keyof T, Array<(data: any) => void>>> = {};
on<K extends keyof T>(event: K, fn: (data: T[K]) => void): void {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event]!.push(fn);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners[event]?.forEach((fn) => fn(data));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("login", (data) => console.log(data.userId));
Best Practices
- Let inference work first—add explicit types only when errors appear.
- Use constraints instead of
anyfor flexible but safe APIs. - Prefer
unknownoveranyin generic defaults when shape is unknown. - Export inferred types with
typeofand helper aliases for consumers. - Avoid overly complex conditional types in app code—extract to named aliases.
- Document generic parameters in public library APIs.
Common Mistakes
- Using
anyin generic position—defeats the purpose of generics. - Forgetting that
Tis fixed per invocation—identitycannot return different types than input. - Over-constraining—blocks valid use cases; use overloads when needed.
- Circular generic references causing compiler errors or
anyleakage. - Ignoring
strictFunctionTypeswhen assigning generic function types.
Interview Questions
- What problem do generics solve compared to union types?
- Explain
keyofandextendsin generic constraints. - Implement a
DeepPartial<T>type. - What does
inferdo in conditional types? - Difference between
interface Generic<T>andtype Generic<T>? - How do you extract element type from
Array<T>? - When would you use generic defaults?
- Explain distributive conditional types with union inputs.
Generic Variance and Function Types
Under strictFunctionTypes, function parameter types are checked more strictly for generic positions. A function that accepts Animal cannot be assigned where Dog is expected as parameter—parameters are contravariant.
type Handler<T> = (value: T) => void;
const handleDog: Handler<Dog> = (dog) => console.log(dog.breed);
// const handleAnimal: Handler<Animal> = handleDog; // Error under strictFunctionTypes
Return types are covariant—a function returning Dog can substitute where Animal is expected.
Understanding variance explains confusing assignability errors in callback-heavy React code.
Const Type Parameters and satisfies
TypeScript 5.0+ const type parameters preserve literal types:
function createConfig<const T extends readonly string[]>(routes: T) {
return routes;
}
const routes = createConfig(["home", "about", "blog"]);
// type: readonly ["home", "about", "blog"]
Combine with satisfies for inference without widening:
const config = {
endpoints: { users: "/api/users", posts: "/api/posts" },
} satisfies Record<string, Record<string, string>>;
These patterns reduce need for manual generic annotations in configuration objects.
Building a Typed Router
type RouteMap = {
"/users": { query: { page: number }; response: User[] };
"/users/:id": { params: { id: string }; response: User };
};
function defineRouter<T extends Record<string, RouteDef>>() {
return {
get<K extends keyof T>(
path: K,
handler: (ctx: T[K]) => Promise<T[K]["response"]>
) { /* register */ },
};
}
Libraries like tRPC extend this idea end-to-end from server to client.
satisfies vs as const
const routes = ["home", "about", "blog"] as const;
type Route = typeof routes[number]; // "home" | "about" | "blog"
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
} satisfies { apiUrl: string; timeout: number };
// config.timeout remains number literal inference where possible
Use satisfies when you want validation without widening inferred literal types to general primitives.
Summary
Generics are TypeScript's mechanism for reusable, type-safe abstractions. Start with functions and interfaces; add constraints for safety; use conditional types and mapped types for advanced transformations. Production-grade TypeScript leans heavily on these patterns—mastering them unlocks better APIs and fewer runtime surprises.