PrimeStack.
Guides·Nov 28, 2025

Mastering TypeScript Generics for Better Type Safety

Take your TypeScript skills to the next level with advanced generic patterns that catch bugs before they hit production.


TypeScript generics are the single most powerful tool for writing reusable, type-safe abstractions. If you've been reaching for any or duplicating function signatures to handle different types, generics are the answer — and mastering them is what separates intermediate TypeScript developers from expert ones.

This guide covers everything from basic generic functions to advanced patterns like conditional types, mapped types, and the infer keyword. By the end, you'll have the vocabulary and the tools to build utilities that are both flexible and fully type-safe.

Table of Contents


Why Generics Exist

The fundamental problem generics solve is the tension between reusability and type safety. Without generics, you face a choice: write a function that works with one specific type, or accept any and lose all type information downstream.

Consider a simple identity function. Without generics, you write function identity(arg: any): any. TypeScript won't complain, but the return type is any, which means any code consuming this function gets no type checking. You've opted out of the type system entirely.

Generics let you say: "I don't care what the type is when I write this, but once a caller provides a type, lock it in consistently." This is reusable, type-safe abstraction — you write logic once and TypeScript enforces correctness for every type variation.


Basic Generic Functions and Interfaces

A type parameter is declared with angle brackets, conventionally <T>, and can be used anywhere in the function signature.

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

const str = identity("hello"); // type: string
const num = identity(42);      // type: number

TypeScript infers T from the argument. You can also provide it explicitly: identity<string>("hello").

Generic interfaces follow the same pattern:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface User {
  id: number;
  name: string;
}

const response: ApiResponse<User> = {
  data: { id: 1, name: "Alice" },
  status: 200,
  message: "OK",
};

Generic classes are equally straightforward:

class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
}

const stack = new Stack<number>();
stack.push(1);
stack.push(2);
stack.pop(); // type: number | undefined

Generic Constraints with extends

Unconstrained generics can be too permissive. If you want to access a property on T, TypeScript won't allow it because T could be anything — including types that don't have that property.

The extends keyword constrains what types are valid for a type parameter:

function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

getLength("hello");    // valid
getLength([1, 2, 3]);  // valid
getLength(42);         // Error: number doesn't have .length

A common pattern is constraining with keyof. This ensures that key is an actual property name of obj:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", active: true };
getProperty(user, "name");   // type: string
getProperty(user, "id");     // type: number
getProperty(user, "email");  // Error: "email" doesn't exist on user

The return type T[K] is an indexed access type — it's the type of the property K on T. This is precise and powerful.


Conditional Types

Conditional types allow types to branch based on a condition: T extends U ? X : Y. If T is assignable to U, the type resolves to X; otherwise, it resolves to Y.

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

This becomes genuinely useful when combined with generics to build type-level logic:

type NonNullable<T> = T extends null | undefined ? never : T;

type C = NonNullable<string | null>;    // string
type D = NonNullable<number | undefined>; // number

When a conditional type is given a union, TypeScript distributes it over each member of the union — this is called distributive conditional types. T extends U ? X : Y where T is A | B becomes (A extends U ? X : Y) | (B extends U ? X : Y).

To prevent distribution, wrap the type in a tuple:

type IsNever<T> = [T] extends [never] ? true : false;

Mapped Types and the keyof Operator

Mapped types iterate over the keys of a type to produce a new type. The keyof operator extracts the union of all keys from a type.

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Optional<T> = {
  [K in keyof T]?: T[K];
};

You can add or remove modifiers with + and -:

type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type Required<T> = {
  [K in keyof T]-?: T[K];
};

Remapping keys with as is available since TypeScript 4.1:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

The infer Keyword

infer is used inside conditional types to declare a type variable to be inferred by TypeScript. This is how you extract types from complex structures.

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function fetchUser(): Promise<User> { /* ... */ }
type FetchUserReturn = ReturnType<typeof fetchUser>; // Promise<User>

Extracting the resolved type of a Promise:

type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;

type Resolved = Awaited<Promise<Promise<string>>>; // string

Extracting function parameter types:

type Parameters<T> = T extends (...args: infer P) => any ? P : never;

function createUser(name: string, age: number): User { /* ... */ }
type CreateUserParams = Parameters<typeof createUser>; // [name: string, age: number]

Template Literal Types

Since TypeScript 4.1, you can use template literal syntax at the type level. Combined with mapped types and infer, this is extremely powerful.

type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"

type RouteParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

type Params = RouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

Utility Types Deep Dive

TypeScript ships with a set of generic utility types in lib.es5.d.ts. Understanding their implementations is the best way to understand generics in practice.

Partial and Required

Partial<T> makes all properties optional. Required<T> makes all properties required by removing ? modifiers.

type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };

Pick and Omit

Pick<T, K> constructs a type with only the specified keys. Omit<T, K> is the inverse.

type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

type UserPreview = Pick<User, "id" | "name">;
type UserWithoutId = Omit<User, "id">;

Record

Record<K, V> constructs an object type with keys K and values V. It's more precise than { [key: string]: V } because it constrains keys.

type Record<K extends keyof any, T> = { [P in K]: T };

type Status = "active" | "inactive" | "pending";
type StatusMap = Record<Status, { label: string; color: string }>;

ReturnType, Parameters, and Awaited

These are the inference utilities most frequently used in practice. Awaited<T> (added in TypeScript 4.5) recursively unwraps Promise types:

async function getUser(): Promise<User> { /* ... */ }

type UserReturn = Awaited<ReturnType<typeof getUser>>; // User

Building Real-World Generic Utilities

Typed Fetch Wrapper

A fetch wrapper that enforces response typing at the call site:

async function typedFetch<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}

// Usage — T is explicit at the call site
const user = await typedFetch<User>("/api/users/1");
const posts = await typedFetch<Post[]>("/api/posts");

Form State Generics

A hook that tracks form state for any given shape:

function useForm<T extends Record<string, unknown>>(initialValues: T) {
  const [values, setValues] = React.useState<T>(initialValues);
  const [errors, setErrors] = React.useState<Partial<Record<keyof T, string>>>({});

  function setValue<K extends keyof T>(key: K, value: T[K]) {
    setValues((prev) => ({ ...prev, [key]: value }));
  }

  return { values, errors, setValue, setErrors };
}

// Usage
const form = useForm({ email: "", password: "" });
form.setValue("email", "user@example.com"); // type-checked
form.setValue("email", 42);                  // Error: number not assignable to string

Typed Event Emitter

type EventMap = Record<string, unknown>;

class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};

  on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void) {
    this.listeners[event] = [...(this.listeners[event] ?? []), listener];
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]) {
    this.listeners[event]?.forEach((fn) => fn(payload));
  }
}

interface AppEvents {
  userLogin: { userId: string; timestamp: number };
  pageView: { path: string };
}

const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("userLogin", ({ userId }) => console.log(userId)); // type-safe payload
emitter.emit("userLogin", { userId: "abc", timestamp: Date.now() });

Common Generic Pitfalls

Overcomplicating for Diminishing Returns

The goal of generics is ergonomics — making call sites simpler and safer. If your generic type takes five type parameters and requires explicit annotation at every call site, it's not ergonomic. Simpler types, even at the cost of some precision, often serve better.

Losing Inference

When you add explicit type annotations to a generic function's arguments, you can break TypeScript's ability to infer the type parameter. Prefer designing functions so inference works automatically:

// Inference works
const result = identity("hello"); // T inferred as string

// Explicit annotation prevents widening
const result2 = identity<string>("hello"); // only do this when necessary

Using Generic When Overloads Are Clearer

Sometimes function overloads express intent more clearly than a single generic signature, especially when the return type depends on the argument type in a non-generic way.


any vs unknown vs Generics

These three are often confused, but they serve distinct purposes.

any opts out of the type system entirely. Values typed as any can be assigned to anything and anything can be assigned to them. It's an escape hatch — useful during migrations, but it propagates unsafety wherever it flows.

unknown is the type-safe alternative to any. A value of type unknown can receive any assignment, but you cannot do anything with it until you narrow the type. This is the right type for data whose shape you genuinely don't know at write time, such as JSON from an external API before validation.

function processInput(input: unknown) {
  if (typeof input === "string") {
    return input.toUpperCase(); // narrowed to string, safe
  }
  throw new Error("Expected string");
}

Generics are for when you know the structure will be consistent, but you don't know the specific type until the call site. Use generics when the caller knows the type. Use unknown when neither the function author nor the caller knows the type and runtime narrowing is required. Avoid any except as a last resort.

The decision tree is simple: can the caller provide a concrete type? Use generics. Is the shape genuinely unknown at runtime? Use unknown. Is everything else failing and you need to ship? Use any with a comment explaining why, and plan to remove it.