Understanding TypeScript Generics
Generics are the feature that trips people up right after they've gotten comfortable with basic TypeScript types — and then, once it clicks, becomes one of the most-used tools in the box. The core idea is small: instead of writing a function or type for one specific type, you write it for "whatever type gets passed in," and TypeScript tracks that type through the rest of the signature for you.
The problem generics solve
Say you want a function that returns the first element of an array.
function firstOfNumbers(arr: number[]): number {
return arr[0];
}
function firstOfStrings(arr: string[]): string {
return arr[0];
}
Two functions, identical logic, different types. You could type it with any:
function first(arr: any[]): any {
return arr[0];
}
This works, but you've thrown away all the information. first([1, 2, 3]) now has type any instead of number, so nothing downstream catches a mistake like first([1, 2, 3]).toUpperCase().
Generics keep the connection between input and output type:
function first<T>(arr: T[]): T {
return arr[0];
}
const n = first([1, 2, 3]); // T inferred as number, n: number
const s = first(['a', 'b', 'c']); // T inferred as string, s: string
T is a type parameter — a placeholder that TypeScript fills in based on what you actually pass, the same way a function parameter gets filled in with a value. You didn't have to write first<number>([1, 2, 3]); TypeScript infers T from the argument.
Constraining what T can be
An unconstrained T can be anything, which means you can't assume it has any particular shape. This won't compile:
function getLength<T>(item: T): number {
return item.length; // Error: Property 'length' does not exist on type 'T'
}
TypeScript is right to complain — T could be number, which has no .length. Constrain it with extends:
function getLength<T extends { length: number }>(item: T): number {
return item.length; // OK — T is guaranteed to have a `length` property
}
getLength('hello'); // OK, strings have .length
getLength([1, 2, 3]); // OK, arrays have .length
getLength(42); // Error: number doesn't satisfy the constraint
extends here doesn't mean class inheritance — it means "T must be assignable to this shape." Any type with a .length number property satisfies it.
Generic interfaces and classes
Generics aren't just for functions — they parameterize types themselves. A Box<T> that can hold any single value:
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: 'hello' };
A more realistic example — a typed API response wrapper used across an app:
interface ApiResponse<T> {
data: T;
status: number;
error: string | null;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return { data, status: res.status, error: null };
}
Every caller of fetchUser gets response.data typed as User, not any — the generic carries the specific payload type through the whole response shape without you writing a separate UserApiResponse, PostApiResponse, and so on for every entity.
Multiple type parameters
Generics can take more than one parameter, which is common for functions that combine two independent things — a map function is the canonical example:
function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const lengths = mapArray(['a', 'bb', 'ccc'], (s) => s.length);
// T inferred as string, U inferred as number
// lengths: number[]
T is the input element type, U is whatever the callback returns — they don't have to match, and TypeScript infers both independently from how you call it.
Default type parameters
Like default function parameters, generics can have defaults, useful when a type parameter is usually the same thing but should stay overridable:
interface PaginatedResponse<T = unknown> {
items: T[];
page: number;
totalPages: number;
}
function emptyPage(): PaginatedResponse {
return { items: [], page: 1, totalPages: 1 }; // T defaults to unknown
}
function usersPage(): PaginatedResponse<User> {
return { items: [], page: 1, totalPages: 1 }; // T explicitly User
}
A common mistake: reaching for generics too early
Not every function with a parameter needs to be generic. If a function only ever operates on string, writing function process<T extends string>(input: T) adds a type parameter that never varies and never earns its complexity. Generics pull their weight when the relationship between input and output type actually needs to be preserved across a call — a first<T> that returns the same element type it received, a Box<T> that wraps arbitrary values, a mapArray<T, U> that transforms one type into another. If the type never changes across usages, a concrete type is clearer than a generic one nobody instantiates differently.
Takeaway
Generics exist to preserve a relationship between types that any would erase and that writing N near-identical overloads would duplicate. The mental model that makes them click: a type parameter is a parameter, just like a function argument, except what you're passing in is a type, and TypeScript tracks it through the rest of the signature the same way it tracks a value.