Generics in TypeScript let you build reusable components that work with any type while keeping full type safety. This guide covers when to use them, how to avoid the most common mistakes, and what actually works in production code.
TypeScript Generics are the difference between writing utility functions that get copied and pasted with type tweaks, and writing one function that handles every case correctly. I've seen codebases where a simple getById function is duplicated fifteen times because nobody set up generics properly. Let's fix that.
Why TypeScript Generics Matters (and When to Skip It)
Generics exist to solve one problem: type-safe reuse. Without them, you either duplicate code or cast your way out of type safety with any, which defeats the entire purpose of TypeScript.
But here's my hot take: you don't need generics everywhere. If your function only ever handles one type, adding a generic parameter is over-engineering. I've reviewed PRs where someone wrapped a simple string function in <T> for no reason. That's ceremony, not engineering.
Skip generics when:
- Your function genuinely only works with one type
- The type complexity outweighs the reuse benefit
- You're writing a one-off internal helper with a single caller
Reach for generics when:
- You're building shared utilities, hooks, or API wrappers
- You're creating data structures that hold any type
- You need to preserve relationships between input and output types
Getting Started with TypeScript Generics
No setup needed — TypeScript supports generics out of the box. Here's the minimal working example:
// The classic: identity function
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // Type: "hello" (string literal)
const num = identity(42); // Type: 42 (number literal)
TypeScript infers the type argument automatically. You rarely need to specify it explicitly:
// Explicit (usually unnecessary)
const explicit = identity<string>("hello");
// Inferred (preferred)
const inferred = identity("hello");
Core TypeScript Generics Concepts Every Developer Should Know
1. Constraints
Constraints limit what types a generic can accept. Use extends to require certain properties:
interface HasId {
id: string;
}
function getById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
interface User extends HasId {
name: string;
}
const users: User[] = [{ id: "1", name: "Alice" }];
const user = getById(users, "1"); // Type: User | undefined
// getById([1, 2, 3], "1"); // Error: number doesn't have id
2. Generic Constraints with keyof
The keyof operator gives you type-safe property access:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Bob", age: 30 };
const name = getProperty(person, "name"); // Type: string
const age = getProperty(person, "age"); // Type: number
// getProperty(person, "email"); // Error: "email" is not a key
3. Generic Types and Interfaces
Generics work with interfaces and type aliases too:
interface ApiResponse<T> {
data: T;
status: number;
error?: string;
}
type User = { id: string; email: string };
function fetchUser(): ApiResponse<User> {
// Simulated API call
return {
data: { id: "1", email: "user@example.com" },
status: 200
};
}
const response = fetchUser();
const userEmail = response.data.email; // Fully typed
4. Default Type Parameters
Set defaults so callers don't always need to specify:
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
const defaultArray = createArray(3, "x"); // string[]
const customArray = createArray<number>(3, 5); // number[]
Common TypeScript Generics Mistakes and How to Fix Them
Mistake 1: Using any instead of constraints
// ❌ Bad: loses all type safety
function getLength<T>(value: any): number {
return value.length;
}
// ✅ Good: constrains to what you actually need
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // works
getLength([1, 2, 3]); // works
// getLength(42); // Error: number doesn't have length
Mistake 2: Over-generalizing return types
// ❌ Bad: loses the specific type relationship
function firstElement<T>(arr: T[]): any {
return arr[0];
}
// ✅ Good: preserves the type relationship
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const nums = [1, 2, 3];
const first = firstElement(nums); // Type: number | undefined
Mistake 3: Forgetting generic inference in callbacks
// ❌ Bad: TypeScript can't infer the callback parameter type
function mapItems<T>(items: T[], callback: (item: any) => any): any[] {
return items.map(callback);
}
// ✅ Good: let TypeScript infer from the generic
function mapItems<T, U>(items: T[], callback: (item: T) => U): U[] {
return items.map(callback);
}
const numbers = [1, 2, 3];
const doubled = mapItems(numbers, (n) => n * 2); // Type: number[]
When Should You Use TypeScript Generics?
Use TypeScript Generics when you're building something that should work with multiple types while maintaining type safety. The sweet spots are:
- API client wrappers — one
get<T>(url: string): Promise<T>handles all your endpoints - Form validation utilities — validate different shapes without casting
- State management helpers — typed selectors and setters for any store shape
- Collection utilities — sort, filter, group functions that work on any data type
The rule of thumb: if you catch yourself writing as SomeType more than twice in a utility function, you probably need a generic.
TypeScript Generics in Production
Three tips from real projects:
1. Keep generic complexity in shared code. Your app-specific components rarely need generics. Put them in your lib/ or utils/ folders where they get reused.
2. Use generic constraints to document intent. A constraint like T extends HasTimestamp tells future developers exactly what the function expects. It's self-documenting code.
3. Test the edge cases. Generics fail silently when you're not careful. Write tests for the boundary cases:
// Test that empty arrays work correctly
const result = firstElement([]); // Should be undefined
// Test that the type relationship holds
const typedResult: number | undefined = firstElement([1, 2, 3]);
Here's a production-worthy example combining everything:
// A paginated API wrapper
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
async function fetchPaginated<T>(
url: string,
page: number = 1
): Promise<PaginatedResponse<T>> {
const response = await fetch(`${url}?page=${page}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
// Usage with full type safety
interface Product {
id: string;
name: string;
price: number;
}
const products = await fetchPaginated<Product>("/api/products");
products.items.forEach(product => {
console.log(product.price); // Fully typed
});
Start by finding one duplicated utility in your codebase that handles multiple types. Convert it to use a generic, add type tests, and see how much cleaner your call sites become. That single refactor will teach you more than any guide.