TypeScript Generics Mastery: The Complete Guide
Master TypeScript generics from the ground up. Covers generic functions, interfaces, classes, constraints, keyof, built-in utility types, conditional types, the infer keyword, mapped types, and 4 real-world architecture patterns with full interview preparation.
Introduction
You've seen them everywhere: Array<string>, Promise<User>, Observable<Event>. Those angle brackets are TypeScript generics — and they're the single most powerful feature in the type system.
Generics allow you to write code that works with any type while still being completely type-safe. Without generics you face a painful choice: lose type safety (use any) or duplicate code for every type you want to support. Generics give you a third path — write once, type safely, use everywhere.
This guide is your complete, intensive reference to TypeScript generics. We'll cover the fundamentals, progressively move through constraints, multiple parameters, conditional types, the infer keyword, real-world patterns, and close with interview preparation. By the end, angle brackets will feel like second nature.
Why Generics Exist
Consider a function that returns the first element of an array:
// ❌ Without generics — loses type information
function first(arr: any[]): any {
return arr[0];
}
const name = first(['Alice', 'Bob']); // type: any — could be anything!
name.toUpperCase(); // No error, but breaks at runtime if arr is empty
// ❌ Without generics — must duplicate for every type
function firstString(arr: string[]): string { return arr[0]; }
function firstNumber(arr: number[]): number { return arr[0]; }
function firstUser(arr: User[]): User { return arr[0]; }
// ... forever
// ✅ With generics — one function, full type safety
function first<T>(arr: T[]): T {
return arr[0];
}
const name = first(['Alice', 'Bob']); // type: string ✅
const age = first([25, 30, 35]); // type: number ✅
const user = first([{ id: 1 }]); // type: { id: number } ✅
The T is a type variable — a placeholder filled in at call time. TypeScript infers it automatically from the argument you pass.
Generic Syntax Fundamentals
Declaring a Type Variable
// Single type variable
function identity<T>(value: T): T {
return value;
}
// Multiple type variables (any letter works, but T, U, K, V are conventional)
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
Calling Generic Functions
TypeScript infers the type from the argument (preferred):
const result = identity('hello'); // T inferred as: string
const num = identity(42); // T inferred as: number
Or you can explicitly pass the type:
const result = identity<string>('hello'); // Explicit — useful when inference fails
Naming Conventions
| Letter | Conventional Meaning |
|---|---|
T | Type (general purpose) |
U, V | Second/third type when T is taken |
K | Key type (especially keyof) |
V | Value type |
E | Element type (arrays) |
R | Return type |
Generic Functions
Basic Patterns
// Swap two values
function swap<T, U>(a: T, b: U): [U, T] {
return [b, a];
}
const [y, x] = swap(1, 'hello'); // [string, number]
// Wrap a value
function wrap<T>(value: T): { value: T } {
return { value };
}
const wrapped = wrap(42); // { value: number }
Generic Arrow Functions
// In .ts files
const identity = <T>(value: T): T => value;
// In .tsx files (JSX), add a trailing comma to avoid ambiguity with JSX tags
const identity = <T,>(value: T): T => value;
Real-World Example: Type-Safe API Fetcher
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
}
// Usage — TypeScript knows exactly what you're getting back
interface User {
id: number;
name: string;
email: string;
}
const user = await fetchData<User>('/api/users/1');
console.log(user.name); // ✅ TypeScript knows this is a string
console.log(user.xyz); // ❌ Error: Property 'xyz' does not exist on User
Generic Interfaces
Interfaces can be generic too, capturing relationships between types:
interface Repository<T> {
findById(id: number): Promise<T>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: number): Promise<void>;
}
// Implement for any entity type
class UserRepository implements Repository<User> {
async findById(id: number): Promise<User> {
return fetchData<User>(`/api/users/${id}`);
}
async findAll(): Promise<User[]> {
return fetchData<User[]>('/api/users');
}
async save(user: User): Promise<User> {
// POST/PUT logic
return user;
}
async delete(id: number): Promise<void> {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
}
}
Generic Response Wrapper
A common real-world pattern:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
// Functions that return consistent shapes
async function getUser(id: number): Promise<ApiResponse<User>> {
// ...
}
async function getProducts(): Promise<ApiResponse<Product[]>> {
// ...
}
Generic Classes
Classes can be parameterized to work with any type:
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];
}
isEmpty(): boolean {
return this.items.length === 0;
}
get size(): number {
return this.items.length;
}
}
const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
numStack.push('hello'); // ❌ Error: Argument of type 'string' not assignable to 'number'
const strStack = new Stack<string>();
strStack.push('hello');
strStack.push('world');
const top = strStack.peek(); // type: string | undefined ✅
Generic Queue
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T | undefined {
return this.items.shift();
}
contains(item: T): boolean {
return this.items.includes(item);
}
}
const taskQueue = new Queue<() => void>();
taskQueue.enqueue(() => console.log('Task 1'));
taskQueue.enqueue(() => console.log('Task 2'));
const task = taskQueue.dequeue();
task?.(); // "Task 1"
Generic Constraints
Sometimes T can be too broad. Constraints let you require that T has certain properties.
The extends Keyword
// T must have a .length property
function logLength<T extends { length: number }>(value: T): T {
console.log(`Length: ${value.length}`);
return value;
}
logLength('hello'); // ✅ string has .length
logLength([1, 2, 3]); // ✅ array has .length
logLength({ length: 10 }); // ✅ object with .length
logLength(42); // ❌ Error: number has no .length
Constraining to an Interface
interface Identifiable {
id: number;
}
function findById<T extends Identifiable>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const users: User[] = [{ id: 1, name: 'Alice', email: 'a@a.com' }];
const user = findById(users, 1); // type: User | undefined ✅
// Works for ANY type that has an `id` field:
const products: Product[] = [...];
const product = findById(products, 5); // type: Product | undefined ✅
keyof Constraint — Safe Property Access
// K must be a key that actually exists in T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Alice', age: 25 };
const name = getProperty(user, 'name'); // type: string ✅
const age = getProperty(user, 'age'); // type: number ✅
const xyz = getProperty(user, 'xyz'); // ❌ Error: 'xyz' not in type ✅
This is how you write type-safe property accessors — no more obj[key as any]!
Multiple Constraints
interface Serializable {
serialize(): string;
}
interface Comparable {
compareTo(other: this): number;
}
// T must satisfy BOTH interfaces
function processItem<T extends Serializable & Comparable>(item: T): string {
return item.serialize();
}
Default Type Parameters
Like default function parameters, but for types:
interface PaginatedResult<T, E = Error> {
data: T[];
total: number;
error?: E;
}
// Without specifying error type — defaults to Error
const result: PaginatedResult<User> = {
data: [],
total: 0
};
// With a custom error type
const result2: PaginatedResult<User, string> = {
data: [],
total: 0,
error: 'Not found'
};
Built-in Utility Types (Generics in Action)
TypeScript ships with powerful utility types, all implemented using generics. Understanding how they work makes you a better TypeScript developer.
Partial and Required
interface User {
id: number;
name: string;
email: string;
age: number;
}
// All fields optional — great for PATCH requests
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number }
// All fields required (even if originally optional)
type RequiredUser = Required<User>;
// Practical use: update function that accepts partial data
function updateUser(id: number, updates: Partial<User>): User {
// Merge with existing user and return
}
updateUser(1, { name: 'Bob' }); // ✅ Only name
updateUser(1, { email: 'b@b.com' }); // ✅ Only email
Pick and Omit
// Select only specific fields
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string }
// Remove specific fields
type UserWithoutId = Omit<User, 'id'>;
// { name: string; email: string; age: number }
// Practical use: form data without the server-generated id
type CreateUserForm = Omit<User, 'id'>;
function createUser(data: CreateUserForm): Promise<User> {
// POST to API, server generates id
}
Record
// Maps keys of type K to values of type V
type UserMap = Record<number, User>;
// { [key: number]: User }
const usersById: Record<number, User> = {
1: { id: 1, name: 'Alice', email: 'a@a.com', age: 25 },
2: { id: 2, name: 'Bob', email: 'b@b.com', age: 30 },
};
// Maps string status to a color string
type StatusColor = Record<'active' | 'inactive' | 'pending', string>;
const statusColors: StatusColor = {
active: 'green',
inactive: 'gray',
pending: 'yellow'
};
ReturnType and Parameters
function fetchUser(id: number, options: { cache: boolean }): Promise<User> {
// ...
}
// Extract the return type
type FetchUserReturn = ReturnType<typeof fetchUser>; // Promise<User>
// Extract the parameters as a tuple
type FetchUserParams = Parameters<typeof fetchUser>; // [number, { cache: boolean }]
// Super useful for mocking, wrapping, and decorating functions
function wrapWithLogging<T extends (...args: any[]) => any>(fn: T) {
return (...args: Parameters<T>): ReturnType<T> => {
console.log('Calling with:', args);
const result = fn(...args);
console.log('Result:', result);
return result;
};
}
Readonly
// Makes all properties immutable
type ImmutableUser = Readonly<User>;
const user: ImmutableUser = { id: 1, name: 'Alice', email: 'a@a.com', age: 25 };
user.name = 'Bob'; // ❌ Error: Cannot assign to 'name' — it is read-only
Conditional Types
Conditional types let you choose between types based on a condition — like a ternary operator for types.
// Syntax: T extends U ? TypeIfTrue : TypeIfFalse
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<'hello'>; // true (string literal extends string)
Distributive Conditional Types
When T is a union, conditional types distribute over each member:
type ToArray<T> = T extends any ? T[] : never;
type StringOrNumberArray = ToArray<string | number>;
// string[] | number[] (not (string | number)[])
NonNullable — Removing null and undefined
// Built-in utility using conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string
Practical Conditional Type — FlattenArray
type FlattenArray<T> = T extends Array<infer Item> ? Item : T;
type StringItem = FlattenArray<string[]>; // string
type NumberItem = FlattenArray<number[]>; // number
type NotAnArray = FlattenArray<boolean>; // boolean (not an array, stays as-is)
The infer Keyword
infer lets you extract a type from another type within a conditional type. It's TypeScript saying "figure out this type for me."
Extracting the Return Type
// This is exactly how ReturnType<T> works internally
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function greet(name: string): string {
return `Hello, ${name}!`;
}
type GreetReturn = MyReturnType<typeof greet>; // string
Extracting the Element Type of an Array
type ElementType<T> = T extends (infer E)[] ? E : never;
type StrElement = ElementType<string[]>; // string
type NumElement = ElementType<number[][]>; // number[] (one level deep)
Extracting Promise Value
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type UserFromPromise = UnwrapPromise<Promise<User>>; // User
type PlainNumber = UnwrapPromise<number>; // number (not a Promise)
Extracting First Parameter
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
function createUser(name: string, age: number): User { /* ... */ }
type FirstArg = FirstParam<typeof createUser>; // string
Mapped Types with Generics
Mapped types transform the properties of an existing type:
// Make all properties optional (same as Partial<T>)
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Add a validation flag to every field
type WithValidation<T> = {
[K in keyof T]: {
value: T[K];
isValid: boolean;
error?: string;
};
};
type UserForm = WithValidation<User>;
// {
// id: { value: number; isValid: boolean; error?: string }
// name: { value: string; isValid: boolean; error?: string }
// email: { value: string; isValid: boolean; error?: string }
// ...
// }
Real-World Patterns
Pattern 1: Type-Safe Event Emitter
type EventMap = {
userLogin: { userId: string; timestamp: Date };
userLogout: { userId: string };
pageView: { path: string; referrer?: string };
};
class TypedEventEmitter<Events extends Record<string, unknown>> {
private listeners = new Map<keyof Events, Function[]>();
on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): void {
const existing = this.listeners.get(event) || [];
this.listeners.set(event, [...existing, listener]);
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
const handlers = this.listeners.get(event) || [];
handlers.forEach(handler => handler(data));
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('userLogin', ({ userId, timestamp }) => {
console.log(`User ${userId} logged in at ${timestamp}`);
});
emitter.emit('userLogin', { userId: '123', timestamp: new Date() }); // ✅
emitter.emit('userLogin', { userId: '123' }); // ❌ Error: missing timestamp
emitter.emit('unknown', {}); // ❌ Error: 'unknown' not in EventMap
Pattern 2: Generic Form Builder
type FormSchema<T> = {
[K in keyof T]: {
label: string;
type: 'text' | 'number' | 'email' | 'select';
required?: boolean;
options?: string[]; // for 'select' type
};
};
function buildForm<T>(schema: FormSchema<T>, initialValues: Partial<T>) {
// Render form dynamically based on schema
// TypeScript ensures schema keys match T's keys
}
buildForm<CreateUserForm>(
{
name: { label: 'Full Name', type: 'text', required: true },
email: { label: 'Email', type: 'email', required: true },
age: { label: 'Age', type: 'number' },
},
{ name: 'Alice' }
);
Pattern 3: Type-Safe State Management
interface Action<T extends string, P = void> {
type: T;
payload: P;
}
type UserActions =
| Action<'SET_USER', User>
| Action<'CLEAR_USER'>
| Action<'UPDATE_EMAIL', { email: string }>;
function userReducer(state: User | null, action: UserActions): User | null {
switch (action.type) {
case 'SET_USER':
return action.payload; // TypeScript knows payload is User ✅
case 'CLEAR_USER':
return null; // TypeScript knows payload is void ✅
case 'UPDATE_EMAIL':
return state ? { ...state, email: action.payload.email } : null; // ✅
}
}
Pattern 4: Generic Memoization
function memoize<Args extends unknown[], R>(fn: (...args: Args) => R) {
const cache = new Map<string, R>();
return (...args: Args): R => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const expensiveCalc = memoize((n: number, factor: number): number => {
// Expensive computation...
return n * factor;
});
expensiveCalc(100, 2); // Computes
expensiveCalc(100, 2); // Returns from cache
expensiveCalc('a', 2); // ❌ Error: 'a' is not assignable to number
Common Mistakes to Avoid
Mistake 1: Using any Instead of a Type Variable
// ❌ WRONG — defeats type safety
function firstAny(arr: any[]): any {
return arr[0];
}
const name = firstAny(['Alice']); // type: any — no safety
// ✅ CORRECT — preserves the type
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const name = first(['Alice']); // type: string
Mistake 2: Constraining Too Broadly
// ❌ TOO BROAD — `object` excludes primitives but loses specific shape info
function merge<T extends object>(a: T, b: T): T {
return { ...a, ...b } as T; // May lose excess properties
}
// ✅ BETTER — use record or intersection
function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
return { ...a, ...b } as T;
}
Mistake 3: Forgetting extends Causes Distribution
type IsArray<T> = T extends any[] ? true : false;
// When T is a union, the conditional distributes over each member:
type Result = IsArray<string | number[]>;
// → IsArray<string> | IsArray<number[]>
// → false | true
// → boolean (not simply true or false!)
// To prevent distribution, wrap in a tuple:
type IsArrayStrict<T> = [T] extends [any[]] ? true : false;
type Result2 = IsArrayStrict<string | number[]>; // false
Mistake 4: Not Narrowing After infer
type GetFirst<T> = T extends [infer F, ...any[]] ? F : never;
type First = GetFirst<[string, number, boolean]>; // string ✅
type Empty = GetFirst<[]>; // never ✅
Complexity Summary
| Concept | Difficulty | Use Case |
|---|---|---|
| Generic function | Beginner | Reusable utilities |
| Generic interface/class | Beginner | Data containers, repos |
Constraints (extends) | Intermediate | Safe property access |
keyof + generics | Intermediate | Dynamic property access |
| Utility types | Intermediate | Transforming existing types |
| Conditional types | Advanced | Type-level logic |
infer | Advanced | Extracting nested types |
| Mapped types | Advanced | Type transformations |
Interview Preparation
Common Interview Questions
Q: What's the difference between any and generics?
anydisables type checking entirely — you lose all IntelliSense and error detection. Generics preserve type relationships: TypeScript still knows the concrete type and checks it at every usage site. Use generics when you want flexibility and safety; useanyalmost never.
Q: When would you use a generic constraint (extends)?
When your function or class needs to call methods or access properties on
T, butTcould be anything. Without a constraint, TypeScript won't let you access.idon an unknownT. AddingT extends { id: number }tells TypeScript the minimum shapeTmust satisfy, enabling safe access while keeping the function flexible.
Q: Explain keyof in combination with generics.
keyof Tproduces a union of all property names ofT. Combined with generics:function get<T, K extends keyof T>(obj: T, key: K): T[K]creates a type-safe property accessor. The return typeT[K](indexed access type) is exactly the type of propertyKonT— eliminating the need for type assertions.
Q: What is a conditional type? Give a real-world example.
A conditional type uses the
T extends U ? X : Ysyntax to choose between types based on a relationship. Real-world example:NonNullable<T> = T extends null | undefined ? never : Tremoves null and undefined from a type. They're also used for extracting types viainfer— e.g.,ReturnType<T>extracts what a function returns.
Q: What does infer do and when is it useful?
inferintroduces a new type variable inside a conditional type that TypeScript fills in by pattern-matching. It's used to extract types from other types: from a function's return type (infer R), from an array's element type (infer E), or from a Promise's resolved value (infer V). Withoutinfer, extracting these types would require manual type assertions.
Q: What are mapped types and how do they relate to generics?
Mapped types create new types by iterating over the keys of a type (
[K in keyof T]) and transforming each. They're always generic since they takeTas an input. The entirePartial<T>,Required<T>,Readonly<T>,Record<K, V>family is built on mapped types. You can add/remove the?(optional) andreadonlymodifiers as part of the mapping.
Conclusion
TypeScript generics transform the language from a simple type annotator into a full type-level programming system. The journey has four stages:
- Generic functions and interfaces — write once, use with any type
- Constraints — keep flexibility while requiring a minimum contract
- Utility types — transform existing types without duplication
- Conditional types and
infer— build type-level logic and extract hidden types
The key insight: generics are just variables for types. Once you internalize that, every new generic pattern becomes intuitive — you're just declaring, constraining, and composing type variables the same way you work with value variables.
💡 tip[!TIP] The best way to get comfortable with generics is to look at TypeScript's own source for utility types (in
lib.es5.d.ts). EveryPartial,Record,ReturnTypeis implemented in plain TypeScript — reading them is the fastest way to level up.
🧠 Test Your Knowledge
Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.