Table of Contents
Introduction
TypeScript has revolutionized JavaScript development by introducing static types, offering better tooling, and catching errors before runtime. Among its most powerful features are generics, which allow you to create reusable components that work with a variety of types while maintaining type safety.
Despite their power, generics can be intimidating for developers new to TypeScript or even those with experience who haven't fully explored their capabilities. In this comprehensive guide, we'll journey from the basics to advanced patterns, illustrating how generics can make your code more flexible, reusable, and type-safe.
Whether you're just starting with TypeScript or looking to level up your type skills, this post will provide practical examples and patterns you can immediately apply to your projects.
What Are Generics?
At their core, generics allow you to write code that works with multiple types while preserving type information throughout your program. Think of them as type variables or placeholders that get replaced with actual types when the code is used.
Without generics, you'd have to choose between:
- Using specific types, limiting reusability
- Using
any
, sacrificing type safety - Duplicating code for different types
Generics give you the best of all worlds: reusable code that preserves type information. Let's see the difference with a simple example:
// Without generics (using any)
function getFirstElement(arr: any[]): any {
return arr[0];
}
// We lose type information
const firstNumber = getFirstElement([1, 2, 3]); // type: any
const firstString = getFirstElement(["a", "b", "c"]); // type: any
// This compiles but would cause runtime errors!
firstNumber.toFixed(2); // No type checking
// With generics
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
// Type information is preserved
const firstNumber = getFirstElement([1, 2, 3]); // type: number | undefined
const firstString = getFirstElement(["a", "b", "c"]); // type: string | undefined
// Type checking works!
firstNumber?.toFixed(2); // Safe access with optional chaining
// firstString?.toFixed(2); // Error: Property 'toFixed' does not exist on type 'string'
In the generic version, T
is a type parameter that gets replaced with the actual type when the function is called. This allows TypeScript to track types throughout your code, providing better type checking and editor support.
Generic Syntax and Basic Patterns
Let's explore the basic syntax of generics and common patterns you'll encounter in TypeScript code.
Generic Functions
Generic functions use type parameters, typically denoted by a single capital letter (conventionally starting with T):
function identity<T>(arg: T): T {
return arg;
}
// Explicit type parameter
const result1 = identity<string>("hello"); // type: string
// Type inference (preferred when possible)
const result2 = identity(42); // type: number
Generic Interfaces
You can create generic interfaces to define reusable shapes:
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "hello" };
// Generic interface with multiple type parameters
interface Pair<K, V> {
key: K;
value: V;
}
const keyValuePair: Pair<string, number> = {
key: "age",
value: 30
};
Generic Classes
Classes can also use generics to create reusable components:
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T | undefined {
return this.items.shift();
}
peek(): T | undefined {
return this.items[0];
}
}
const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
const firstItem = numberQueue.dequeue(); // type: number | undefined
const stringQueue = new Queue<string>();
stringQueue.enqueue("hello");
stringQueue.enqueue("world");
const greeting = stringQueue.peek(); // type: string | undefined
Generic Type Aliases
Type aliases can also be generic, allowing you to create reusable type definitions:
type Result<T, E = Error> = {
success: true;
data: T;
} | {
success: false;
error: E;
};
function fetchData(): Result<string> {
try {
// Simulating API call
return {
success: true,
data: "Successfully fetched data"
};
} catch (e) {
return {
success: false,
error: e as Error
};
}
}
const result = fetchData();
if (result.success) {
console.log(result.data); // type: string
} else {
console.error(result.error); // type: Error
}
Generic Constraints
Sometimes you want to limit what types can be used with your generics. This is where constraints come in, using the extends
keyword:
// Basic constraint
interface HasLength {
length: number;
}
function getLength<T extends HasLength>(item: T): number {
return item.length;
}
// Works for strings, arrays, and any type with a length property
getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength({ length: 10 }); // 10
// getLength(42); // Error: number doesn't have a length property
Constraints are particularly useful when you want to ensure certain properties or methods are available:
// Ensuring an object has specific properties
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = {
name: "Alice",
age: 30,
city: "Wonderland"
};
// TypeScript knows the return type based on the key
const name = getProperty(person, "name"); // type: string
const age = getProperty(person, "age"); // type: number
// const height = getProperty(person, "height"); // Error: "height" is not a key of person
Using Multiple Constraints
You can combine multiple constraints using intersection types:
interface Printable {
print(): void;
}
interface Loggable {
log(): void;
}
// T must implement both Printable and Loggable
function printAndLog<T extends Printable & Loggable>(item: T): void {
item.print();
item.log();
}
class Document implements Printable, Loggable {
constructor(private content: string) {}
print() {
console.log(`Printing: ${this.content}`);
}
log() {
console.log(`Logging: ${this.content}`);
}
}
const doc = new Document("Hello World");
printAndLog(doc); // Works fine
// Error: Object literal doesn't implement required interfaces
// printAndLog({
// print: () => console.log("Printing")
// // missing log method
// });
Default Type Parameters
Like function parameters, type parameters can have defaults:
// Default type parameter
interface ApiResponse<T = any> {
data: T;
status: number;
message: string;
}
// No need to specify the type parameter
const response1: ApiResponse = {
data: "success",
status: 200,
message: "OK"
};
// We can be explicit when needed
const response2: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: "John" },
status: 200,
message: "OK"
};
Default type parameters are especially useful for configuration objects or options:
interface PaginationOptions<T = unknown> {
data: T[];
page: number;
perPage: number;
total: number;
}
function createPagination<T>(
data: T[],
options: Partial<Omit<PaginationOptions<T>, 'data'>> = {}
): PaginationOptions<T> {
return {
data,
page: options.page ?? 1,
perPage: options.perPage ?? 10,
total: options.total ?? data.length
};
}
const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
const pagination = createPagination(users);
// {
// data: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }],
// page: 1,
// perPage: 10,
// total: 2
// }
Advanced Patterns with Generics
Mapped Types
Mapped types allow you to create new types based on existing ones by transforming each property in some way:
// Making all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Making all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Making all properties nullable
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
interface User {
id: number;
name: string;
email: string;
}
// All properties are optional
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; }
// All properties are readonly
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }
// All properties can be null
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null; }
Conditional Types
Conditional types allow you to select types based on a condition:
// Conditional type using the ternary operator syntax
type NonNullable<T> = T extends null | undefined ? never : T;
// Remove properties from T that are assignable to U
type Omit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
};
// Extract types from a union that are assignable to U
type Extract<T, U> = T extends U ? T : never;
type StringOrNumber = string | number | boolean;
type JustStrings = Extract<StringOrNumber, string>; // string
// Use case: Extracting properties of a certain type
type User = {
id: number;
name: string;
isAdmin: boolean;
createdAt: Date;
};
// Extract all string properties from User
type StringProps<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K]
};
type UserStringProps = StringProps<User>; // { name: string }
Recursive Types
Generics can be used to create self-referential or recursive types:
// Recursive type for a tree structure
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
// JSON value recursive type
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
// Deep partial type (recursively makes all properties optional)
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
interface NestedObject {
name: string;
details: {
age: number;
address: {
street: string;
city: string;
};
};
}
// All properties are optional at every level
type PartialNestedObject = DeepPartial<NestedObject>;
const partial: PartialNestedObject = {
name: "John",
details: {
// age is optional
address: {
// street is optional
city: "New York"
}
}
};
Real-World Examples
Type-Safe Event Emitter
Here's a type-safe event emitter using generics:
// Define event map type
interface EventMap {
[eventName: string]: any;
}
// Type-safe event emitter
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[E in keyof Events]?: Array<(data: Events[E]) => void>;
} = {};
// Add event listener
on<E extends keyof Events>(event: E, listener: (data: Events[E]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]?.push(listener);
}
// Remove event listener
off<E extends keyof Events>(event: E, listener: (data: Events[E]) => void): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
this.listeners[event] = eventListeners.filter(l => l !== listener);
}
}
// Emit event
emit<E extends keyof Events>(event: E, data: Events[E]): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
eventListeners.forEach(listener => listener(data));
}
}
}
// Usage
interface MyEvents {
userLoggedIn: { userId: string; timestamp: number };
dataLoaded: { items: string[] };
error: Error;
}
const emitter = new TypedEventEmitter<MyEvents>();
// Type-safe event names and data
emitter.on("userLoggedIn", data => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.on("dataLoaded", data => {
console.log(`Loaded ${data.items.length} items`);
});
emitter.emit("userLoggedIn", {
userId: "user123",
timestamp: Date.now()
});
// Type error: wrong event data structure
// emitter.emit("userLoggedIn", { userId: "user123" }); // Error: missing timestamp
// Type error: event doesn't exist
// emitter.emit("userRegistered", { userId: "user123" }); // Error: event not in MyEvents
Generic React Components
Generics are commonly used in React to create flexible, reusable components:
import React, { useState } from 'react';
// Generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T, index: number) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul className="list">
{items.map((item, index) => (
<li key={keyExtractor(item, index)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Generic form input with value type
interface InputProps<T> {
value: T;
onChange: (value: T) => void;
label?: string;
formatter?: (value: T) => string;
parser?: (input: string) => T;
}
function Input<T>({
value,
onChange,
label,
formatter = String,
parser = (v: string) => v as unknown as T
}: InputProps<T>) {
const [inputValue, setInputValue] = useState(formatter(value));
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
onChange(parser(e.target.value));
};
return (
<div className="input-group">
{label && <label>{label}</label>}
<input type="text" value={inputValue} onChange={handleChange} />
</div>
);
}
// Usage in a component
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
return (
<div>
<h2>Users</h2>
<List<User>
items={users}
keyExtractor={(user) => user.id.toString()}
renderItem={(user) => (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)}
/>
<Input<number>
value={42}
onChange={(value) => console.log(value)}
label="Age"
formatter={(value) => value.toString()}
parser={(input) => parseInt(input, 10)}
/>
</div>
);
}
Type-Safe API Client
Build a type-safe API client with generics:
// Define endpoint configurations
interface ApiEndpoints {
'/users': {
get: {
params: { limit?: number };
response: { users: { id: number; name: string }[] };
};
post: {
body: { name: string; email: string };
response: { id: number; name: string; email: string };
};
};
'/users/:id': {
get: {
params: { id: number };
response: { id: number; name: string; email: string };
};
put: {
params: { id: number };
body: { name?: string; email?: string };
response: { id: number; name: string; email: string };
};
delete: {
params: { id: number };
response: { success: boolean };
};
};
}
// Generic API client
class ApiClient {
constructor(private baseUrl: string) {}
// GET request
async get<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path] & 'get'
>(
path: Path,
params: ApiEndpoints[Path][Method] extends { params: infer P } ? P : never
): Promise<
ApiEndpoints[Path][Method] extends { response: infer R } ? R : unknown
> {
// Build URL with path and query parameters
const url = new URL(this.baseUrl + this.buildPath(path, params));
// Add query parameters
Object.entries(params).forEach(([key, value]) => {
if (this.isQueryParam(path, key)) {
url.searchParams.append(key, String(value));
}
});
// Make the request
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
// POST request
async post<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path] & 'post'
>(
path: Path,
params: ApiEndpoints[Path][Method] extends { params: infer P } ? P : Record<string, never>,
body: ApiEndpoints[Path][Method] extends { body: infer B } ? B : never
): Promise<
ApiEndpoints[Path][Method] extends { response: infer R } ? R : unknown
> {
// Build URL with path parameters
const url = new URL(this.baseUrl + this.buildPath(path, params));
// Make the request
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
// Helper method to build path with parameters
private buildPath(path: string, params: Record<string, any>): string {
return path.replace(/:([a-zA-Z0-9_]+)/g, (_, param) => {
const value = params[param];
if (value === undefined) {
throw new Error(`Missing path parameter: ${param}`);
}
return String(value);
});
}
// Helper to check if a parameter is a query parameter
private isQueryParam(path: string, param: string): boolean {
return !path.includes(`:${param}`);
}
}
// Usage
const api = new ApiClient('https://api.example.com');
// Type-safe API calls
async function fetchUsers() {
// Types are inferred correctly
const response = await api.get('/users', { limit: 10 });
console.log(response.users.length); // OK
// console.log(response.otherProp); // Error: Property doesn't exist
}
async function createUser() {
const user = await api.post('/users', {}, {
name: 'John Doe',
email: 'john@example.com'
});
console.log(user.id, user.name, user.email); // All correctly typed
}
async function getUser() {
const user = await api.get('/users/:id', { id: 1 });
console.log(user.id, user.name, user.email); // All correctly typed
}
Tips and Best Practices
Here are some tips and best practices for using generics effectively:
- Use descriptive type parameter names: While
T
,U
,V
are conventional for simple cases, use descriptive names likeTItem
,TKey
,TValue
for clarity in complex scenarios. - Apply constraints thoughtfully: Use constraints to ensure you have the properties or methods you need, but don't over-constrain and limit reusability.
- Prefer inference over explicit types: Let TypeScript infer types when possible rather than explicitly declaring them.
- Combine with utility types: TypeScript's built-in utility types like
Partial
,Pick
,Omit
, andRecord
work well with generics. - Use conditional types for more complex logic: When you need to choose types based on other types, conditional types are powerful.
- Don't overuse: Not everything needs to be generic. Sometimes a simple union type or interface is clearer.
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
department: string;
createdAt: Date;
}
// Pick specific properties
type UserBasicInfo = Pick<User, 'id' | 'name' | 'email'>;
// Omit specific properties
type UserWithoutDates = Omit<User, 'createdAt'>;
// Make specific properties required
type PartialUserWithRequiredId = Partial<User> & Pick<User, 'id'>;
// Record: Create a dictionary with keys of one type and values of another
type UsersByRole = Record<User['role'], User[]>;
// Combining generics with utility types
function createSafeRecord<T extends object, K extends keyof T>(
items: T[],
getKey: (item: T) => K
): Record<string, T> {
return items.reduce((acc, item) => {
const key = getKey(item);
acc[String(key)] = item;
return acc;
}, {} as Record<string, T>);
}
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin', department: 'IT', createdAt: new Date() },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user', department: 'Marketing', createdAt: new Date() }
];
const usersById = createSafeRecord(users, user => user.id);
const usersByName = createSafeRecord(users, user => user.name);
Conclusion
TypeScript's generics are a powerful feature that bring flexibility and type safety to your code. By creating reusable components that work with multiple types while maintaining type information, you can write cleaner, safer, and more maintainable code.
From basic functions and interfaces to advanced patterns like mapped types, conditional types, and recursive types, generics provide tools to tackle complex type relationships. They enable you to create type-safe APIs, event systems, state management, and more.
As with any powerful feature, the key is to use generics judiciously. Start with simple cases, and as you grow more comfortable, incorporate more advanced patterns where they provide clear benefits.
By mastering TypeScript generics, you'll add a valuable tool to your programming arsenal, one that will help you build more robust applications with fewer runtime errors and better developer experiences.
Further Reading
Additional resources to deepen your understanding of TypeScript generics:
Key Resources
The official guide to TypeScript's generics and type system.
Basarat Ali Syed's comprehensive guide to TypeScript.
Dan Vanderkam's 62 ways to improve your TypeScript.
A reference for TypeScript's advanced type features.