TypeScript

TypeScript Generics Explained: From Basics to Real Patterns

Understand generic syntax, constraints, inference, and utility types through examples you will use in APIs, React components, and data layers.

May 9, 20258 min read
TS
TypeScript

TypeScript Generics Explained: From Basics to Real Patterns

DevPulse AI
Share:

Generics are the mechanism TypeScript uses to write reusable, type-safe abstractions without giving up precision. They appear in Array<T>, React's useState<T>, and every Promise<T>. Yet many developers copy generic syntax from Stack Overflow without knowing when to introduce a type parameter, how inference works, or why a constraint fixes a cryptic error.

This article builds generics from first principles through patterns you will recognize in production codebases.

The problem generics solve

Without generics, you choose between duplication and loss of type information:

function firstString(arr: string[]): string {
  return arr[0];
}

function firstNumber(arr: number[]): number {
  return arr[0];
}

One generic function preserves the element type:

function first<T>(arr: T[]): T {
  return arr[0];
}

const n = first([1, 2, 3]); // number
const s = first(["a", "b"]); // string

The type parameter T is a placeholder filled in when the function is called. The compiler connects input and output: whatever T is, first returns T.

Generic functions and inference

Usually you omit explicit type arguments—TypeScript infers T from parameters:

first([true, false]); // T inferred as boolean

When inference fails, pass explicitly:

const rows = first<User>(maybeEmpty); // when maybeEmpty is User[] | undefined and you narrowed

Multiple type parameters model relationships:

function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

Generic interfaces and types

Types can be generic too:

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };

async function fetchJson<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  if (!res.ok) return { success: false, error: res.statusText };
  const data = (await res.json()) as T;
  return { success: true, data };
}

T flows through callers—fetchJson<User>("/api/me") types data as User on the success branch.

Constraints with extends

If you need to access properties on T, constrain it:

function getId<T extends { id: string }>(entity: T): string {
  return entity.id;
}

keyof constraints connect keys to object shapes:

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

const email = pluck(user, "email"); // string

Common mistake: over-constraining. Start unconstrained; add extends only when the compiler demands it.

Generic classes

class Stack<T> {
  private items: T[] = [];
  push(item: T) { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
}

Instances fix T: new Stack<number>(). Static members cannot reference instance type parameters—use separate static generics if needed.

React component generics

Function components can be generic with a subtle syntax:

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map((item) => <li key={String(item)}>{renderItem(item)}</li>)}</ul>;
}

Usage: <List items={users} renderItem={(u) => u.name} /> infers T as User.

Hooks like useState<User | null>(null) fix state type explicitly when initial value is null.

Default type parameters

type Paginated<T, PageSize extends number = 20> = {
  items: T[];
  pageSize: PageSize;
  cursor: string | null;
};

Defaults reduce noise when most callers want the standard page size.

Conditional types (light introduction)

Generics power conditional types:

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

infer extracts types inside conditionals—used in advanced utilities:

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

You use these via built-ins (ReturnType, Parameters, Awaited) before authoring your own.

Mapped types and keyof patterns

type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

PartialBy<User, "email"> makes only email optional—handy for update DTOs.

Understanding generics makes Record, Pick, Omit, and Readonly readable instead of magic.

Variance intuition (practical level)

For function parameters, TypeScript checks assignability conservatively. Passing Dog[] where Animal[] expected can be unsafe for mutation (push(new Cat())). Read-only arrays and immutable data structures reduce pain.

When generics fail on function props, check whether you need readonly T[] or a covariant-friendly pattern.

Anti-patterns

  • <T = any> defaults erase safety—prefer unknown or no default
  • Generic everythingfunction log<T>(msg: T) adds nothing; use string
  • Casting inside genericsreturn data as T hides validation gaps
  • Ten-parameter generics — split into objects or builder types

Practice exercises (mental)

  1. Write mapAsync<T, U>(items: T[], fn: (t: T) => Promise<U>): Promise<U[]>
  2. Write createStore<T extends object>(initial: T) returning [T, (patch: Partial<T>) => void] typed tuple
  3. Type a groupBy that returns Record<K, T[]> with K extends string | number

Debugging generic errors

  • Read the inferred type hover in VS Code on the failing call
  • Simplify: replace generic body with return type annotation temporarily
  • Instantiate explicitly to see if inference or implementation is wrong
  • Check strict optional and exactOptionalPropertyTypes interactions

Real-world patterns in libraries you use

React Query preserves data types through useQuery<User> so data is User | undefined, not unknown.

Zod infers output types from schemas: z.infer<typeof UserSchema> connects runtime validation to static types—generics bridge parse results and domain models.

Express/Fastify route typing with generics on handlers is improving; many teams wrap frameworks in typed routers where path params and bodies are generic parameters tied to a route map.

Studying one library's type definitions in node_modules (jump to definition) teaches more than reading abstract tutorials—look for how they constrain T extends QueryKey or similar.

Building your own typed utilities

Teams often need a typed pick, event emitter, or repository layer:

interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
}

Implement once per aggregate (User, Order) with T carrying fields. Avoid Repository<any>—you lose autocomplete exactly where data access bugs hurt.

Generics and documentation

JSDoc @template T helps IDEs on JavaScript migration paths. For exported APIs, document constraints in prose: "T must include id for cache keys."

When not to use generics (revisited)

If you have only one implementation ever—parseConfig() reading your single config.json—explicit types are clearer. Generics earn their complexity when duplication would otherwise produce any or unsafe casts across three or more call sites.

Covariance and arrays (practical warning)

Cat[] is not safely assignable to Animal[] if you might push a Dog. TypeScript allows some array assignments that are unsound at runtime—when modeling animals use ReadonlyArray or avoid mutable shared arrays across types.

Generic type aliases vs interfaces

type Box<T> = { value: T };

Interfaces can merge declarations; type aliases suit unions and mapped types. Pick one style per module for readability.

Practice project ideas

Build a tiny typed event bus on<EventMap, K extends keyof EventMap>(event: K, handler: (payload: EventMap[K]) => void)—excellent generics exercise under two hundred lines.

Build a typed fetch wrapper that infers JSON bodies from a route map. You will feel inference limits quickly—that is the lesson.

Read TypeScript release notes annually—const type parameters and improved inference land regularly. One new feature per quarter in your codebase beats cramming every advanced type at once.

When stuck, simplify generics to concrete types first, make it work, then reintroduce type parameters once duplication appears—same refactor loop as extracting functions.

Pair generic utilities with unit tests that assert type inference using expectTypeOf—tests document intended relationships better than comments alone and catch accidental widening during refactors.

Conclusion

Generics are how TypeScript shares code without sharing types. Master inference, constraints, and keyof relationships; reach for conditional and mapped types when utilities repeat across modules.

Start every abstraction without generics; add type parameters only when duplicate overloads or any would otherwise appear. That discipline keeps APIs approachable while preserving the precision that makes TypeScript worth the build step entirely. Revisit generics when the third copy-paste appears—that is usually the right moment to generalize safely. Until then, concrete types ship faster and stay easier for reviewers to approve without debating variance theory in every single pull request.

Generics in API design

Expose generic helpers only when callers supply meaningful type parameters—otherwise use concrete types. Document constraints (extends) in TSDoc so inference failures make sense. Watch bundle size when generics instantiate huge unions in emitted declarations. For library authors, test inference with dtslint or equivalent. Generics should remove repetition, not impress readers. When teaching generics to your team, pair each abstraction with a failing example that breaks inference—those failures teach faster than reading utility type source.

Workshop: apply this week

Pick one idea from this article and ship it before Friday. Write a short internal note explaining what changed, what metric you expect to move, and how you will verify the result. Share the note with your team so the learning compounds. If the experiment fails, document the failure mode—it is as valuable as success for the next engineer reading this guide.

Frequently asked questions

What is the point of TypeScript generics?
Generics let you write functions, types, and components that work across multiple types while preserving the relationship between input and output types—so a function that returns what you pass in is typed accurately, not as any or a loose union.
When should I add a generic constraint?
Add extends when you need to access properties or methods on the type parameter—e.g., T extends { id: string } for entities with IDs. Without constraints, the type parameter is unknown shape except what you assign to it.
Why does TypeScript sometimes infer generics and sometimes default to {}?
Inference follows argument positions and contextual types. Empty object defaults often mean insufficient context—pass explicit type arguments or annotate parameters to guide the compiler.

Comments

Discussion is coming soon. Share this article and join the conversation on social media.

Enjoyed this article?

Get weekly engineering guides delivered to your inbox.