TypeScript generics are the single most powerful tool for writing reusable, type-safe abstractions. If you've been reaching for any or duplicating function signatures to handle different types, generics are the answer — and mastering them is what separates intermediate TypeScript developers from expert ones.
This guide covers everything from basic generic functions to advanced patterns like conditional types, mapped types, and the infer keyword. By the end, you'll have the vocabulary and the tools to build utilities that are both flexible and fully type-safe.
Table of Contents
- Why Generics Exist
- Basic Generic Functions and Interfaces
- Generic Constraints with extends
- Conditional Types
- Mapped Types and the keyof Operator
- The infer Keyword
- Template Literal Types
- Utility Types Deep Dive
- Building Real-World Generic Utilities
- Common Generic Pitfalls
- any vs unknown vs Generics
Why Generics Exist
The fundamental problem generics solve is the tension between reusability and type safety. Without generics, you face a choice: write a function that works with one specific type, or accept any and lose all type information downstream.
Consider a simple identity function. Without generics, you write function identity(arg: any): any. TypeScript won't complain, but the return type is any, which means any code consuming this function gets no type checking. You've opted out of the type system entirely.
Generics let you say: "I don't care what the type is when I write this, but once a caller provides a type, lock it in consistently." This is reusable, type-safe abstraction — you write logic once and TypeScript enforces correctness for every type variation.
Basic Generic Functions and Interfaces
A type parameter is declared with angle brackets, conventionally <T>, and can be used anywhere in the function signature.
function identity<T>(arg: T): T {
return arg;
}
const str = identity("hello"); // type: string
const num = identity(42); // type: numberTypeScript infers T from the argument. You can also provide it explicitly: identity<string>("hello").
Generic interfaces follow the same pattern:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
}
const response: ApiResponse<User> = {
data: { id: 1, name: "Alice" },
status: 200,
message: "OK",
};Generic classes are equally straightforward:
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];
}
}
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
stack.pop(); // type: number | undefinedGeneric Constraints with extends
Unconstrained generics can be too permissive. If you want to access a property on T, TypeScript won't allow it because T could be anything — including types that don't have that property.
The extends keyword constrains what types are valid for a type parameter:
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
getLength("hello"); // valid
getLength([1, 2, 3]); // valid
getLength(42); // Error: number doesn't have .lengthA common pattern is constraining with keyof. This ensures that key is an actual property name of obj:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice", active: true };
getProperty(user, "name"); // type: string
getProperty(user, "id"); // type: number
getProperty(user, "email"); // Error: "email" doesn't exist on userThe return type T[K] is an indexed access type — it's the type of the property K on T. This is precise and powerful.
Conditional Types
Conditional types allow types to branch based on a condition: T extends U ? X : Y. If T is assignable to U, the type resolves to X; otherwise, it resolves to Y.
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // falseThis becomes genuinely useful when combined with generics to build type-level logic:
type NonNullable<T> = T extends null | undefined ? never : T;
type C = NonNullable<string | null>; // string
type D = NonNullable<number | undefined>; // numberWhen a conditional type is given a union, TypeScript distributes it over each member of the union — this is called distributive conditional types. T extends U ? X : Y where T is A | B becomes (A extends U ? X : Y) | (B extends U ? X : Y).
To prevent distribution, wrap the type in a tuple:
type IsNever<T> = [T] extends [never] ? true : false;Mapped Types and the keyof Operator
Mapped types iterate over the keys of a type to produce a new type. The keyof operator extracts the union of all keys from a type.
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};You can add or remove modifiers with + and -:
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type Required<T> = {
[K in keyof T]-?: T[K];
};Remapping keys with as is available since TypeScript 4.1:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }The infer Keyword
infer is used inside conditional types to declare a type variable to be inferred by TypeScript. This is how you extract types from complex structures.
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function fetchUser(): Promise<User> { /* ... */ }
type FetchUserReturn = ReturnType<typeof fetchUser>; // Promise<User>Extracting the resolved type of a Promise:
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
type Resolved = Awaited<Promise<Promise<string>>>; // stringExtracting function parameter types:
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number): User { /* ... */ }
type CreateUserParams = Parameters<typeof createUser>; // [name: string, age: number]Template Literal Types
Since TypeScript 4.1, you can use template literal syntax at the type level. Combined with mapped types and infer, this is extremely powerful.
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"
type RouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | RouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = RouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"Utility Types Deep Dive
TypeScript ships with a set of generic utility types in lib.es5.d.ts. Understanding their implementations is the best way to understand generics in practice.
Partial and Required
Partial<T> makes all properties optional. Required<T> makes all properties required by removing ? modifiers.
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };Pick and Omit
Pick<T, K> constructs a type with only the specified keys. Omit<T, K> is the inverse.
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type UserPreview = Pick<User, "id" | "name">;
type UserWithoutId = Omit<User, "id">;Record
Record<K, V> constructs an object type with keys K and values V. It's more precise than { [key: string]: V } because it constrains keys.
type Record<K extends keyof any, T> = { [P in K]: T };
type Status = "active" | "inactive" | "pending";
type StatusMap = Record<Status, { label: string; color: string }>;ReturnType, Parameters, and Awaited
These are the inference utilities most frequently used in practice. Awaited<T> (added in TypeScript 4.5) recursively unwraps Promise types:
async function getUser(): Promise<User> { /* ... */ }
type UserReturn = Awaited<ReturnType<typeof getUser>>; // UserBuilding Real-World Generic Utilities
Typed Fetch Wrapper
A fetch wrapper that enforces response typing at the call site:
async function typedFetch<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// Usage — T is explicit at the call site
const user = await typedFetch<User>("/api/users/1");
const posts = await typedFetch<Post[]>("/api/posts");Form State Generics
A hook that tracks form state for any given shape:
function useForm<T extends Record<string, unknown>>(initialValues: T) {
const [values, setValues] = React.useState<T>(initialValues);
const [errors, setErrors] = React.useState<Partial<Record<keyof T, string>>>({});
function setValue<K extends keyof T>(key: K, value: T[K]) {
setValues((prev) => ({ ...prev, [key]: value }));
}
return { values, errors, setValue, setErrors };
}
// Usage
const form = useForm({ email: "", password: "" });
form.setValue("email", "user@example.com"); // type-checked
form.setValue("email", 42); // Error: number not assignable to stringTyped Event Emitter
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void) {
this.listeners[event] = [...(this.listeners[event] ?? []), listener];
}
emit<K extends keyof Events>(event: K, payload: Events[K]) {
this.listeners[event]?.forEach((fn) => fn(payload));
}
}
interface AppEvents {
userLogin: { userId: string; timestamp: number };
pageView: { path: string };
}
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("userLogin", ({ userId }) => console.log(userId)); // type-safe payload
emitter.emit("userLogin", { userId: "abc", timestamp: Date.now() });Common Generic Pitfalls
Overcomplicating for Diminishing Returns
The goal of generics is ergonomics — making call sites simpler and safer. If your generic type takes five type parameters and requires explicit annotation at every call site, it's not ergonomic. Simpler types, even at the cost of some precision, often serve better.
Losing Inference
When you add explicit type annotations to a generic function's arguments, you can break TypeScript's ability to infer the type parameter. Prefer designing functions so inference works automatically:
// Inference works
const result = identity("hello"); // T inferred as string
// Explicit annotation prevents widening
const result2 = identity<string>("hello"); // only do this when necessaryUsing Generic When Overloads Are Clearer
Sometimes function overloads express intent more clearly than a single generic signature, especially when the return type depends on the argument type in a non-generic way.
any vs unknown vs Generics
These three are often confused, but they serve distinct purposes.
any opts out of the type system entirely. Values typed as any can be assigned to anything and anything can be assigned to them. It's an escape hatch — useful during migrations, but it propagates unsafety wherever it flows.
unknown is the type-safe alternative to any. A value of type unknown can receive any assignment, but you cannot do anything with it until you narrow the type. This is the right type for data whose shape you genuinely don't know at write time, such as JSON from an external API before validation.
function processInput(input: unknown) {
if (typeof input === "string") {
return input.toUpperCase(); // narrowed to string, safe
}
throw new Error("Expected string");
}Generics are for when you know the structure will be consistent, but you don't know the specific type until the call site. Use generics when the caller knows the type. Use unknown when neither the function author nor the caller knows the type and runtime narrowing is required. Avoid any except as a last resort.
The decision tree is simple: can the caller provide a concrete type? Use generics. Is the shape genuinely unknown at runtime? Use unknown. Is everything else failing and you need to ship? Use any with a comment explaining why, and plan to remove it.