Understanding TypeScript Generics
A comprehensive deep dive into TypeScript generics. Learn how to write flexible, reusable, and type-safe code using generic types, constraints, utility types, and real-world patterns.
Understanding TypeScript Generics
Generics are one of the most powerful features in TypeScript, yet they often intimidate developers who are new to strongly typed languages. In this deep dive, we will demystify generics, explore their syntax, and work through practical examples that demonstrate why they are indispensable for writing robust, reusable code.
What Are Generics?
At their core, generics allow you to write code that works with multiple types while still maintaining full type safety. Think of them as type variables -- placeholders for types that are specified later when the code is actually used.
Without generics, you would face an unpleasant choice:
// Option 1: Specific but not reusable
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
// Option 2: Reusable but not type-safe
function getFirstAny(arr: any[]): any {
return arr[0];
}
With generics, you get the best of both worlds:
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = getFirst([1, 2, 3]); // type: number | undefined
const str = getFirst(["a", "b", "c"]); // type: string | undefined
const obj = getFirst([{ id: 1 }]); // type: { id: number } | undefined
The <T> syntax declares a type parameter named T. When you call getFirst([1, 2, 3]), TypeScript infers that T is number and enforces that throughout the function.
Generic Functions
Let us look at several common patterns for generic functions.
Identity Function
The simplest generic function is the identity function, which returns exactly what it receives:
function identity<T>(value: T): T {
return value;
}
const result = identity("hello"); // type: string
Multiple Type Parameters
Functions can accept multiple type parameters:
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const p = pair("hello", 42); // type: [string, number]
Generic Arrow Functions
Arrow functions can also be generic. Note the syntax difference in TSX files:
// Standard TypeScript file
const wrap = <T>(value: T): { data: T } => ({ data: value });
// In TSX files, add a trailing comma to disambiguate from JSX
const wrapTsx = <T,>(value: T): { data: T } => ({ data: value });
Generic Interfaces and Types
Generics are not limited to functions. They work beautifully with interfaces and type aliases.
Generic Interfaces
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
// Usage
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "Alice", email: "alice@example.com" },
status: 200,
message: "Success",
timestamp: new Date(),
};
const productResponse: ApiResponse<Product> = {
data: { id: 1, name: "Widget", price: 29.99 },
status: 200,
message: "Success",
timestamp: new Date(),
};
Generic Type Aliases
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { success: false, error: "Division by zero" };
}
return { success: true, data: a / b };
}
const result = divide(10, 2);
if (result.success) {
console.log(result.data); // TypeScript knows data is number here
} else {
console.log(result.error); // TypeScript knows error is string here
}
Notice the E = Error syntax -- this is a default type parameter, meaning if you do not specify the error type, it defaults to Error.
Generic Constraints
Sometimes you need to restrict which types a generic can accept. This is where constraints come in, using the extends keyword.
Basic Constraints
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(value: T): T {
console.log(`Length: ${value.length}`);
return value;
}
logLength("hello"); // OK: strings have .length
logLength([1, 2, 3]); // OK: arrays have .length
logLength({ length: 10 }); // OK: object has .length property
// logLength(123); // Error: number doesn't have .length
The keyof Constraint
One of the most useful patterns is constraining a type parameter to be a key of another type:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30, email: "alice@example.com" };
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number
// getProperty(user, "phone"); // Error: "phone" is not a key of user
This pattern ensures that you can only access properties that actually exist on the object, and the return type is correctly inferred.
Constraining to Object Types
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = merge({ name: "Alice" }, { age: 30 });
// type: { name: string } & { age: number }
// merge("hello", "world"); // Error: string is not an object
Generic Classes
Classes can also be generic, which is particularly useful for data structures:
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;
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
const top = numberStack.pop(); // type: number | undefined
const stringStack = new Stack<string>();
stringStack.push("hello");
// stringStack.push(42); // Error: number is not assignable to string
Advanced Patterns
Let us explore some more advanced generic patterns that you will encounter in real-world TypeScript codebases.
Conditional Types
Conditional types let you express type logic:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Practical example: extract the return type of a promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type X = UnwrapPromise<Promise<string>>; // string
type Y = UnwrapPromise<number>; // number
The infer Keyword
The infer keyword lets you extract types from within other types:
type GetArrayElement<T> = T extends (infer U)[] ? U : never;
type Elem = GetArrayElement<string[]>; // string
type Elem2 = GetArrayElement<number[]>; // number
// Extract function parameter types
type FirstParam<T> = T extends (first: infer P, ...args: any[]) => any
? P
: never;
type P = FirstParam<(name: string, age: number) => void>; // string
Mapped Types with Generics
Generics combine powerfully with mapped types:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User {
id: number;
name: string;
email: string;
}
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string }
type OptionalUser = Optional<User>;
// { id?: number; name?: string; email?: string }
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
Generic Factory Pattern
A common pattern in React applications is a generic factory for creating typed hooks or components:
interface CrudOperations<T> {
getAll: () => Promise<T[]>;
getById: (id: string) => Promise<T>;
create: (data: Omit<T, "id">) => Promise<T>;
update: (id: string, data: Partial<T>) => Promise<T>;
delete: (id: string) => Promise<void>;
}
function createCrudService<T extends { id: string }>(
baseUrl: string
): CrudOperations<T> {
return {
getAll: async () => {
const res = await fetch(baseUrl);
return res.json();
},
getById: async (id) => {
const res = await fetch(`${baseUrl}/${id}`);
return res.json();
},
create: async (data) => {
const res = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
},
update: async (id, data) => {
const res = await fetch(`${baseUrl}/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
},
delete: async (id) => {
await fetch(`${baseUrl}/${id}`, { method: "DELETE" });
},
};
}
// Usage
interface Post {
id: string;
title: string;
content: string;
}
const postService = createCrudService<Post>("/api/posts");
// postService.getAll() returns Promise<Post[]>
// postService.getById("1") returns Promise<Post>
// postService.create({ title: "Hi", content: "..." }) returns Promise<Post>
Common Pitfalls and Tips
Here are some things to keep in mind when working with generics:
-
Do not over-generalize. If a function only ever works with one type, you do not need generics. Use them when you genuinely need flexibility.
-
Name your type parameters meaningfully. While
Tis conventional for a single parameter, use descriptive names for complex scenarios:// Less clear function transform<T, U>(input: T, fn: (item: T) => U): U; // More clear function transform<Input, Output>( input: Input, fn: (item: Input) => Output ): Output; -
Leverage type inference. You usually do not need to explicitly specify type parameters -- TypeScript is excellent at inferring them:
// Unnecessary -- TypeScript infers <number> const result = getFirst<number>([1, 2, 3]); // Better -- let TypeScript infer it const result = getFirst([1, 2, 3]); -
Use constraints wisely. Only add constraints when you actually need to access specific properties or methods of the type parameter.
Conclusion
Generics are the cornerstone of writing flexible, reusable, and type-safe TypeScript code. They may seem daunting at first, but once you internalize the core concepts -- type parameters, constraints, and inference -- they become second nature.
Here is a quick reference of what we covered:
- Basic generics -- Type parameters with
<T>syntax - Constraints -- Limiting types with
extends keyofpatterns -- Type-safe property access- Generic classes -- Type-safe data structures
- Conditional types -- Type-level logic with
extendsandinfer - Mapped types -- Transforming existing types into new ones
- Factory patterns -- Creating reusable, typed abstractions
Start small by applying generics to your utility functions and data-fetching layers. As you gain confidence, you will find yourself reaching for more advanced patterns naturally.
Have questions about generics or TypeScript in general? Drop a comment below -- we would love to hear from you!