Learn

/

Generics

Generics

8 patterns

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`.

Avoid
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 wrong
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 wrong

Prefer
function 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 compiler
function 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 compiler
Why avoid

Using 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.

Why prefer

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.

TypeScript Handbook: Generics
Avoid
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);

Prefer
function identity<T>(value: T): T {
  return value;
}

// Let TS infer the type parameter
const result = identity("hello"); // string
const num = identity(42); // number
function identity<T>(value: T): T {
  return value;
}

// Let TS infer the type parameter
const result = identity("hello"); // string
const num = identity(42); // number
Why avoid

Explicitly 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.

Why prefer

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.

TypeScript Handbook: Generic type variables
Avoid
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'
}

Prefer
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 length
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 length
Why avoid

An 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.

Why prefer

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.

TypeScript Handbook: Generic constraints
Avoid
interface ApiResponse {
  data: any;
  error: string | null;
}

const res: ApiResponse = await fetchUser();
// res.data is 'any', no type safety
res.data.name; // Could be anything
interface ApiResponse {
  data: any;
  error: string | null;
}

const res: ApiResponse = await fetchUser();
// res.data is 'any', no type safety
res.data.name; // Could be anything

Prefer
interface 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 works
interface 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 works
Why avoid

Using 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.

Why prefer

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.

TypeScript Handbook: Generic types
Avoid
function getProperty(obj: any, key: string) {
  return obj[key]; // Returns any
}

const user = { name: "Alice", age: 30 };
getProperty(user, "naem"); // No error, typo undetected
function getProperty(obj: any, key: string) {
  return obj[key]; // Returns any
}

const user = { name: "Alice", age: 30 };
getProperty(user, "naem"); // No error, typo undetected

Prefer
function 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 keyof
function 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 keyof
Why avoid

With 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.

Why prefer

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.

TypeScript Handbook: Type parameters in constraints
Avoid
// 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,
};

Prefer
// 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,
};
Why avoid

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.

Why prefer

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.

TypeScript Handbook: Generic parameter defaults
Avoid
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"],
});

Prefer
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"],
// });
Why avoid

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.

Why prefer

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.

Total TypeScript: NoInfer
Avoid
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 string
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 string

Prefer
function 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 preserved
function 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 preserved
Why avoid

Without 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.

Why prefer

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.

TypeScript 5.0: const type parameters