Generics
Type parameters, constraints, inference, and when to let TypeScript figure it out. You'll hit this when you write a function that should work with multiple types but you keep reaching for `any`.
function first(arr: any[]): any {
return arr[0];
}
const val = first([1, 2, 3]);
// val is 'any', no autocomplete
val.toFixed(2); // No error even if wrongfunction first(arr: any[]): any {
return arr[0];
}
const val = first([1, 2, 3]);
// val is 'any', no autocomplete
val.toFixed(2); // No error even if wrongfunction first<T>(arr: T[]): T {
return arr[0];
}
const val = first([1, 2, 3]);
// val is 'number', full autocomplete
val.toFixed(2); // OK, checked by compilerfunction first<T>(arr: T[]): T {
return arr[0];
}
const val = first([1, 2, 3]);
// val is 'number', full autocomplete
val.toFixed(2); // OK, checked by compilerUsing any throws away all type information. The return value is any, which means TypeScript cannot catch mistakes like calling string methods on a number. Generics give you flexibility without losing safety.
A generic type parameter T preserves the relationship between input and output. TypeScript infers T as number from the argument, so the return type is number with full type safety. You get autocomplete and compile-time error checking.
function identity<T>(value: T): T {
return value;
}
// Redundant: TS can infer this
const result = identity<string>("hello");
const num = identity<number>(42);function identity<T>(value: T): T {
return value;
}
// Redundant: TS can infer this
const result = identity<string>("hello");
const num = identity<number>(42);function identity<T>(value: T): T {
return value;
}
// Let TS infer the type parameter
const result = identity("hello"); // string
const num = identity(42); // numberfunction identity<T>(value: T): T {
return value;
}
// Let TS infer the type parameter
const result = identity("hello"); // string
const num = identity(42); // numberExplicitly specifying <string> and <number> when TypeScript already infers them correctly adds clutter without benefit. It also creates a maintenance burden: if the argument type changes, you need to update the type parameter too.
TypeScript infers generic type parameters from the arguments you pass. Writing identity("hello") automatically infers T as string. Specifying the type explicitly is redundant noise when inference works correctly. Save explicit type arguments for cases where inference fails or gives the wrong result.
function getLength<T>(value: T): number {
return value.length;
// Error: Property 'length' does not
// exist on type 'T'
}function getLength<T>(value: T): number {
return value.length;
// Error: Property 'length' does not
// exist on type 'T'
}function getLength<T extends { length: number }>(
value: T
): number {
return value.length; // OK
}
getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength(42); // Error: number has no lengthfunction getLength<T extends { length: number }>(
value: T
): number {
return value.length; // OK
}
getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength(42); // Error: number has no lengthAn unconstrained T could be anything, including types without a .length property. TypeScript correctly rejects the access because it cannot guarantee the property exists. The fix is a constraint, not a type assertion.
Adding extends { length: number } constrains T to types that have a length property. TypeScript knows .length is safe inside the function, and rejects arguments that lack it. Constraints let you use specific properties while keeping the function generic.
interface ApiResponse {
data: any;
error: string | null;
}
const res: ApiResponse = await fetchUser();
// res.data is 'any', no type safety
res.data.name; // Could be anythinginterface ApiResponse {
data: any;
error: string | null;
}
const res: ApiResponse = await fetchUser();
// res.data is 'any', no type safety
res.data.name; // Could be anythinginterface ApiResponse<T> {
data: T;
error: string | null;
}
interface User {
name: string;
email: string;
}
const res: ApiResponse<User> = await fetchUser();
// res.data is User, fully typed
res.data.name; // string, autocomplete worksinterface ApiResponse<T> {
data: T;
error: string | null;
}
interface User {
name: string;
email: string;
}
const res: ApiResponse<User> = await fetchUser();
// res.data is User, fully typed
res.data.name; // string, autocomplete worksUsing any for the data field means every consumer of the response loses type information. You cannot get autocomplete on res.data, and typos like res.data.nmae will not be caught. Generic interfaces solve this cleanly.
Making ApiResponse generic with <T> lets you specify the shape of data at each usage site. When you write ApiResponse<User>, the data field is typed as User with full autocompletion. One interface works for every endpoint.
function getProperty(obj: any, key: string) {
return obj[key]; // Returns any
}
const user = { name: "Alice", age: 30 };
getProperty(user, "naem"); // No error, typo undetectedfunction getProperty(obj: any, key: string) {
return obj[key]; // Returns any
}
const user = { name: "Alice", age: 30 };
getProperty(user, "naem"); // No error, typo undetectedfunction getProperty<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // string
getProperty(user, "naem"); // Error: not in keyoffunction getProperty<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // string
getProperty(user, "naem"); // Error: not in keyofWith any and string, TypeScript has no idea which keys are valid or what the return type should be. Typos like "naem" compile without errors and return undefined at runtime. The keyof constraint catches these mistakes statically.
Using K extends keyof T constrains the key to actual properties of the object. TypeScript catches typos at compile time and infers the correct return type via T[K]. For "name", the return type is string. For "age", it is number.
// Forces every caller to specify the type
interface PaginatedList<T> {
items: T[];
page: number;
totalPages: number;
}
// Error: Generic type requires 1 type argument
const meta: PaginatedList = {
items: [],
page: 1,
totalPages: 10,
};// Forces every caller to specify the type
interface PaginatedList<T> {
items: T[];
page: number;
totalPages: number;
}
// Error: Generic type requires 1 type argument
const meta: PaginatedList = {
items: [],
page: 1,
totalPages: 10,
};// Default makes the parameter optional
interface PaginatedList<T = unknown> {
items: T[];
page: number;
totalPages: number;
}
// When you know the type
const users: PaginatedList<User> = { ... };
// When you only care about pagination
const meta: PaginatedList = {
items: [],
page: 1,
totalPages: 10,
};// Default makes the parameter optional
interface PaginatedList<T = unknown> {
items: T[];
page: number;
totalPages: number;
}
// When you know the type
const users: PaginatedList<User> = { ... };
// When you only care about pagination
const meta: PaginatedList = {
items: [],
page: 1,
totalPages: 10,
};Without a default, every usage of PaginatedList must specify a type argument. This is cumbersome when the consumer does not use the items array or when the items are heterogeneous. Default parameters make generics more ergonomic.
Default type parameters (like T = unknown) let callers omit the type argument when they do not need a specific type. This is useful for utility interfaces where some consumers care about the item type and others just need the pagination metadata.
function createFSM<S extends string>(config: {
initial: S;
states: S[];
}) { }
// "not-a-state" widens the union, no error!
createFSM({
initial: "not-a-state",
states: ["open", "closed"],
});function createFSM<S extends string>(config: {
initial: S;
states: S[];
}) { }
// "not-a-state" widens the union, no error!
createFSM({
initial: "not-a-state",
states: ["open", "closed"],
});function createFSM<S extends string>(config: {
initial: NoInfer<S>;
states: S[];
}) { }
createFSM({
initial: "open", // OK
states: ["open", "closed"],
});
// createFSM({
// initial: "not-a-state", // Error
// states: ["open", "closed"],
// });function createFSM<S extends string>(config: {
initial: NoInfer<S>;
states: S[];
}) { }
createFSM({
initial: "open", // OK
states: ["open", "closed"],
});
// createFSM({
// initial: "not-a-state", // Error
// states: ["open", "closed"],
// });Without NoInfer, TypeScript infers the generic from all positions. Any value you pass to initial is included in the inferred union, so there is no way to restrict it to the values in states. Invalid values silently widen the type instead of causing an error.
NoInfer<T> (TypeScript 5.4) marks a position as not an inference site. TypeScript infers S only from states, then checks initial against that inferred type. This gives you a "driver" parameter that defines the set and a "consumer" parameter that must pick from it.
function routes<T extends Record<string, string>>(
config: T
): T {
return config;
}
const r = routes({
home: "/",
about: "/about",
});
// Type: { home: string; about: string }
// Literal paths are widened to stringfunction routes<T extends Record<string, string>>(
config: T
): T {
return config;
}
const r = routes({
home: "/",
about: "/about",
});
// Type: { home: string; about: string }
// Literal paths are widened to stringfunction routes<const T extends Record<string, string>>(
config: T
): T {
return config;
}
const r = routes({
home: "/",
about: "/about",
});
// Type: { readonly home: "/"; readonly about: "/about" }
// Literal paths are preservedfunction routes<const T extends Record<string, string>>(
config: T
): T {
return config;
}
const r = routes({
home: "/",
about: "/about",
});
// Type: { readonly home: "/"; readonly about: "/about" }
// Literal paths are preservedWithout const, TypeScript applies its default widening rules: string literals become string, number literals become number, and arrays become mutable arrays. The caller loses the specific values they passed in, which defeats the purpose of inferring from the argument.
Adding const to a type parameter (TypeScript 5.0) tells the compiler to infer the narrowest possible type from the argument. String and number literals are preserved instead of widened, and arrays become readonly tuples. Before this feature, callers had to write as const at every call site.