TypeScript Generics Guide: From Basics to Advanced Patterns
Why Generics Matter
Generics are TypeScript's most powerful feature for writing reusable, type-safe code. They let you create functions, classes, and interfaces that work with multiple types without sacrificing the compiler's ability to catch errors. Instead of falling back to any, generics preserve the actual type flowing through your code.
Without generics, you face a constant trade-off: write type-specific code that is safe but rigid, or use any for flexibility and lose type checking entirely. Generics eliminate this dilemma by parameterizing types just like functions parameterize values.
This guide covers generics from basic syntax through advanced patterns like conditional types, mapped types, and utility type construction. Every concept includes runnable code examples you can paste into your editor.
Generic Functions: The Foundation
The simplest generic is a function that captures its argument type and uses it in the return type. The type parameter (conventionally named T) acts as a placeholder that gets filled in when the function is called.
// Without generics: loses type information
function firstElement(arr: any[]): any {
return arr[0];
}
// With generics: preserves the exact type
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = firstElement([1, 2, 3]); // type: number
const str = firstElement(["a", "b"]); // type: string
const obj = firstElement([{ id: 1 }]); // type: { id: number }TypeScript infers the type parameter from the argument, so you rarely need to specify it explicitly. When the compiler cannot infer, you can provide it manually:
// Explicit type argument
const result = firstElement<string>(["hello", "world"]);
// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const p = pair("age", 30); // type: [string, number]Generic Constraints with extends
Unconstrained generics accept any type, which means you cannot access properties on the type parameter. Constraints narrow the set of allowed types so you can safely access specific properties or methods.
// Error: Property 'length' does not exist on type 'T'
function getLength<T>(item: T): number {
return item.length; // TS error
}
// Fixed with constraint
function getLength<T extends { length: number }>(item: T): number {
return item.length; // OK - T must have .length
}
getLength("hello"); // OK: string has .length
getLength([1, 2, 3]); // OK: array has .length
getLength(42); // Error: number has no .lengthA common pattern is constraining to an interface to ensure objects have required fields:
interface HasId {
id: string | number;
}
function findById<T extends HasId>(items: T[], id: T["id"]): T | undefined {
return items.find(item => item.id === id);
}
interface User extends HasId { id: number; name: string; }
interface Product extends HasId { id: string; title: string; }
const users: User[] = [{ id: 1, name: "Alice" }];
const found = findById(users, 1); // type: User | undefinedGeneric Interfaces and Type Aliases
Generics are not limited to functions. Interfaces and type aliases can be generic too, which is how you create reusable data structures like API responses, collections, and state containers.
// Generic API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Usage - the type parameter fills in 'data'
type UserResponse = ApiResponse<User>;
// { data: User; status: number; message: string; timestamp: string; }
type ProductListResponse = ApiResponse<Product[]>;
// { data: Product[]; status: number; ... }
// Generic result type (like Rust's Result)
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJSON<T>(json: string): Result<T> {
try {
return { ok: true, value: JSON.parse(json) };
} catch (e) {
return { ok: false, error: e as Error };
}
}Generic Classes
Generic classes are essential for building type-safe data structures. The type parameter is available to all instance methods and properties.
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];
}
get size(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.pop(); // type: number | undefined
// TypeScript infers generic from constructor args
class Pair<A, B> {
constructor(public first: A, public second: B) {}
swap(): Pair<B, A> {
return new Pair(this.second, this.first);
}
}
const p = new Pair("hello", 42);
const swapped = p.swap(); // Pair<number, string>The keyof Operator and Indexed Access Types
The keyof operator produces a union of all property names of a type. Combined with generics, it enables type-safe property access patterns.
// keyof creates a union of property names
type UserKeys = keyof User; // "id" | "name"
// Type-safe property getter
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: "Alice" };
const name = getProperty(user, "name"); // type: string
const id = getProperty(user, "id"); // type: number
getProperty(user, "email"); // Error: not a key of User
// Type-safe pick function
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => { result[key] = obj[key]; });
return result;
}
const partial = pick(user, ["name"]); // { name: string }Conditional Types
Conditional types select one of two types based on a condition, using the syntax T extends U ? X : Y. They are the type-level equivalent of ternary expressions and unlock powerful type transformations.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
// Practical: extract array element type
type ElementOf<T> = T extends (infer E)[] ? E : never;
type NumEl = ElementOf<number[]>; // number
type StrEl = ElementOf<string[]>; // string
// Extract return type (simplified ReturnType)
type Return<T> = T extends (...args: any[]) => infer R ? R : never;
type FnReturn = Return<() => string>; // string
// Distributive conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
type Clean = NonNullable<string | null | undefined>; // stringMapped Types
Mapped types iterate over the keys of a type and transform each property. They are the backbone of utility types like Partial, Required, Readonly, and Record.
// How Partial works internally
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// How Readonly works internally
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Custom: make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface Config {
host: string;
port: number;
debug: boolean;
}
type NullableConfig = Nullable<Config>;
// { host: string | null; port: number | null; debug: boolean | null; }
// Key remapping with 'as' (TS 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type ConfigGetters = Getters<Config>;
// { getHost: () => string; getPort: () => number; getDebug: () => boolean; }Template Literal Types
Template literal types combine string literals with type parameters to create powerful string pattern types. They enable type-safe CSS class names, API routes, event handlers, and more.
// Event handler type
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
// API route builder
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiRoute = `/api/${string}`;
// Type-safe CSS class builder
type Size = "sm" | "md" | "lg";
type Color = "red" | "blue" | "green";
type ClassName = `text-${Size}` | `bg-${Color}`;Real-World Patterns
Here are generic patterns you will use repeatedly in production TypeScript codebases.
Type-Safe Event Bus
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"cart:add": { productId: string; quantity: number };
};
class TypedEventBus<Events extends Record<string, any>> {
private handlers = new Map<string, Set<Function>>();
on<K extends keyof Events>(
event: K,
handler: (data: Events[K]) => void
): void {
if (!this.handlers.has(event as string)) {
this.handlers.set(event as string, new Set());
}
this.handlers.get(event as string)!.add(handler);
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.handlers.get(event as string)?.forEach(fn => fn(data));
}
}
const bus = new TypedEventBus<EventMap>();
bus.on("user:login", (data) => {
console.log(data.userId); // fully typed
});Type-Safe API Client
// Define API shape
interface ApiEndpoints {
"/users": { response: User[]; params: { page?: number } };
"/users/:id": { response: User; params: { id: number } };
"/products": { response: Product[]; params: { category?: string } };
}
// Type-safe fetch wrapper
async function api<T extends keyof ApiEndpoints>(
endpoint: T,
params?: ApiEndpoints[T]["params"]
): Promise<ApiEndpoints[T]["response"]> {
const url = new URL(endpoint, "https://api.example.com");
if (params) {
Object.entries(params).forEach(([k, v]) => {
url.searchParams.set(k, String(v));
});
}
const res = await fetch(url.toString());
return res.json();
}
// Usage - fully typed responses
const users = await api("/users", { page: 1 }); // User[]
const user = await api("/users/:id", { id: 42 }); // UserCommon Mistakes and How to Avoid Them
| Mistake | Problem | Fix |
|---|---|---|
| Too many type params | Unreadable signatures | Max 2-3 type params; extract interfaces |
| Unnecessary generics | Complexity without benefit | Only use when return type depends on input |
| Using any as constraint | Defeats the purpose | Use unknown or a specific interface |
| Not using defaults | Verbose call sites | Add defaults: <T = string> |
| Ignoring variance | Unexpected assignability | Use in/out modifiers (TS 4.7+) |
Frequently Asked Questions
What are generics in TypeScript?
Generics allow you to write functions, classes, and interfaces that work with any type while preserving type safety. Instead of using any, generics capture the actual type passed by the caller, enabling the compiler to enforce correct usage throughout the code. They are TypeScript's primary mechanism for code reuse without sacrificing type checking.
When should I use generics vs union types?
Use generics when the return type depends on the input type and you need to preserve that relationship through the function. Use union types when you accept a fixed set of types but do not need to track which one was passed. For example, a first() function on arrays should be generic (returns the same element type), while an id field that can be string or number is a union.
What is the difference between extends and implements with generics?
In a generic constraint like <T extends HasId>, extends means the type parameter T must be assignable to HasId. The implements keyword is for classes implementing interfaces and cannot be used in generic constraints. Constraints restrict what types are valid for the type parameter.
Validate Your TypeScript with BytePane Tools
Working with generic types that produce JSON output? Use our free JSON Formatter to validate and pretty-print API responses, or test your regex patterns with the Regex Tester.
Open JSON FormatterRelated Articles
TypeScript Utility Types
Partial, Pick, Omit, Record, and custom utility types explained.
JSON Schema Validation
Validate data structures with JSON Schema and TypeScript.
REST API Design Principles
Best practices for HTTP methods, error handling, and versioning.
JWT Tokens Explained
JWT structure, signing algorithms, and authentication patterns.