DEV Community

Cover image for TypeScript Generics: The Complete Guide (with Cheat Sheet)
Parsa Jiravand
Parsa Jiravand

Posted on

TypeScript Generics: The Complete Guide (with Cheat Sheet)

You write a function that works on numbers. Then you need the same function for strings. You copy-paste it and change the type annotation. Then arrays. Then objects. Now you have four functions with identical bodies and different type signatures — or you gave up and typed everything any, and the compiler stopped helping you at the one place you needed it most: the boundary between what you pass in and what you get back.

What you'll learn

By the end of this guide you'll be able to:

  • Write generic functions that preserve the exact type of their input in their output, instead of widening it to any or unknown
  • Constrain a type parameter with extends so the compiler enforces a shape without collapsing to one concrete type
  • Give a type parameter a sensible default, the way you'd default a function argument
  • Read and write generic interfaces, types, and classes with more than one type parameter
  • Diagnose the most common generic inference failures instead of reaching for any to silence them

Who this is for: you've written TypeScript with interfaces and function signatures, and you've seen <T> in library code, but you want the model that makes it predictable instead of memorized syntax.

Contents

Why generics exist

Here's the naive fix for "I need this function to work on more than one type" — widen the parameter to any:

function firstElement(arr: any): any {
  return arr[0];
}

const n = firstElement([1, 2, 3]);      // typed as `any` — no autocomplete, no error checking
const s = firstElement(["a", "b"]);     // also `any` — TypeScript has no idea it's a string
n.toUpperCase();                        // compiles fine, crashes at runtime: not a string
Enter fullscreen mode Exit fullscreen mode

The function works at runtime for both calls. But the moment you use the result, TypeScript has nothing to say — any erases the connection between what went in and what came out. You've traded a compile-time error for a runtime crash, which is the one trade TypeScript exists to prevent.

The alternative most people try next is copy-pasting one function per type:

function firstNumber(arr: number[]): number { return arr[0]; }
function firstString(arr: string[]): string { return arr[0]; }
// ...one more function for every type you ever call this with
Enter fullscreen mode Exit fullscreen mode

This is type-safe, but it doesn't scale — you're maintaining N identical function bodies, and a bug fix means updating all of them. Generics solve exactly this: one function body, and a type that adapts to whatever you call it with, while the compiler still checks that the relationship holds.

The mental model: a type parameter is a function argument for types

The mental model: a generic <T> is a parameter, exactly like a function parameter — except instead of a value, you're passing in a type, and the compiler substitutes it everywhere T appears and checks that everything stays consistent.

Compare the two side by side:

// a regular function: `x` is a placeholder for a VALUE, filled in at the call site
function double(x: number): number {
  return x * 2;
}
double(5); // x = 5

// a generic function: `T` is a placeholder for a TYPE, filled in at the call site
function identity<T>(x: T): T {
  return x;
}
identity(5);      // T = number
identity("hi");   // T = string
Enter fullscreen mode Exit fullscreen mode

double's parameter list says "give me a number, I'll give you a number." identity's type parameter list says "give me some type T, and I promise to give you back that same T" — it's a contract about the shape of the relationship, not about one fixed type. TypeScript infers T from the argument you actually pass, the same way it infers the type of a variable from its initializer — you rarely write identity<number>(5) explicitly; the compiler figures out T = number on its own.

That's the whole idea. Everything below is this one substitution rule, applied to interfaces, constraints, defaults, and classes.

Stage 1: your first generic function

Fix the firstElement function from earlier with a type parameter instead of any:

function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const n = firstElement([1, 2, 3]);    // n: number | undefined
const s = firstElement(["a", "b"]);   // s: string | undefined
n?.toFixed(2);                        // ✅ compiler knows n might be a number
s?.toUpperCase();                     // ✅ compiler knows s might be a string
Enter fullscreen mode Exit fullscreen mode

Key concept: T is inferred once, from the argument, and then reused for the return type. The function body never mentions number or string — it stays generic, but every call site gets a fully concrete, checked type back.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The T | undefined return type matters too: an empty array is a valid T[], so the function is honest that there might be nothing at index 0. This is a case where any would have silently hidden a real edge case that T | undefined forces you to handle.

Generic functions can also take more than the type parameter implies. A map-style helper needs a second, ordinary parameter — a callback — that also mentions T:

function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

const lengths = mapArray(["a", "bb", "ccc"], (s) => s.length); // lengths: number[]
Enter fullscreen mode Exit fullscreen mode

Here T is inferred from arr (string) and U is inferred from what fn returns (number) — two independent type parameters, each pinned down by a different argument.

Stage 2: generic interfaces and types

The same substitution rule applies to interface and type, not just functions. A generic interface is a shape with a type-level blank to fill in:

interface Box<T> {
  value: T;
}

const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "hello" };
// const bad: Box<number> = { value: "hello" }; // ❌ string is not assignable to number
Enter fullscreen mode Exit fullscreen mode

Box<T> isn't a type by itself — it's a template for a type. Box<number> and Box<string> are the actual types, produced by substituting T. This is the exact same mechanism as Array<T> (the type behind T[]), Promise<T>, and Map<K, V> from the standard library — you've been using generics since your first Promise<void>, you just hadn't named the mechanism yet.

Generic type aliases work identically and are common for API response shapes:

type ApiResponse<T> = {
  data: T;
  error: string | null;
};

async function getUser(id: string): Promise<ApiResponse<User>> {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Every endpoint reuses one ApiResponse<T> shape instead of a hand-written UserResponse, PostResponse, OrderResponse trio that all say the same thing with different names.

Stage 3: constraining a type parameter with extends

Unconstrained, T could be anything — which means you can't assume it has any properties at all:

function getLength<T>(item: T): number {
  return item.length; // ❌ Property 'length' does not exist on type 'T'
}
Enter fullscreen mode Exit fullscreen mode

The compiler is right to reject this: nothing says T has a .length. The fix is a constraintextends here doesn't mean "inherits from," it means "must be assignable to":

interface HasLength {
  length: number;
}

function getLength<T extends HasLength>(item: T): number {
  return item.length; // ✅ every T that reaches this line is guaranteed to have `.length`
}

getLength("hello");        // ✅ strings have .length
getLength([1, 2, 3]);      // ✅ arrays have .length
getLength({ length: 10 }); // ✅ any object shape with a length field
getLength(42);              // ❌ number has no .length
Enter fullscreen mode Exit fullscreen mode

Key concept: T extends HasLength narrows which types are legal, not what T collapses to. T is still inferred per call — a string, an array, or a custom object — the constraint only guarantees the one property you need.

A very common constraint pattern uses keyof to guarantee a key actually exists on an object, which is what makes a type-safe getProperty helper possible:

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

const user = { name: "Ada", age: 36 };
getProperty(user, "name");   // ✅ inferred as string
getProperty(user, "email");  // ❌ "email" is not a key of { name: string; age: number }
Enter fullscreen mode Exit fullscreen mode

K extends keyof T reads as "K must be one of T's actual property names." Once that holds, T[K] — an indexed access type — gives you the precise type of that property, so getProperty(user, "age") returns number, not some generic unknown.

Stage 4: default type parameters

A type parameter can have a default, exactly like a function parameter can have a default value — used when the caller doesn't supply one:

interface FetchOptions<TResponse = unknown> {
  url: string;
  parse?: (raw: string) => TResponse;
}

const raw: FetchOptions = { url: "/ping" };                 // TResponse defaults to unknown
const typed: FetchOptions<{ ok: boolean }> = {               // explicit, overrides the default
  url: "/health",
  parse: (raw) => JSON.parse(raw),
};
Enter fullscreen mode Exit fullscreen mode

Defaults matter most on widely-used generic types where most callers don't care about every parameter — a state-management or component-library type might have four type parameters with sensible defaults so 90% of call sites only ever write the one that matters to them, and the rest fall back silently.

Stage 5: multiple type parameters and generic classes

Nothing restricts you to one T. Multiple type parameters are common wherever there's a key/value or input/output relationship:

function zip<A, B>(a: A[], b: B[]): [A, B][] {
  return a.map((item, i) => [item, b[i]]);
}

zip([1, 2], ["a", "b"]); // [number, string][] — inferred as [[1, "a"], [2, "b"]]
Enter fullscreen mode Exit fullscreen mode

Classes take type parameters the same way functions do — on the class itself, available to every method and property inside:

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

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

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

  get size(): number {
    return this.items.length;
  }
}

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

const strings = new Stack<string>();
strings.push("a");
// strings.push(1); // ❌ number is not assignable to string
Enter fullscreen mode Exit fullscreen mode

T is fixed once, at new Stack<number>(), and every method on that instance uses that same T for the rest of its life — push and pop stay in sync automatically, without repeating the type anywhere else in the class body.

Edge cases and gotchas

  • Type erasure at runtime. Generics are a compile-time-only construct — they're erased entirely from the emitted JavaScript. You cannot check typeof T or branch on T at runtime; if you need runtime knowledge of the type, you must pass it explicitly as a value (a constructor, a discriminant field, or a plain string tag), because by the time your code runs, T no longer exists.
  • Inference can fail on object literals with contextual typing. identity({ name: "Ada" }) infers T as { name: string }, which is often what you want, but if you need a wider inferred literal type (e.g. keeping a string as a specific literal rather than widening to string), you'll need as const on the argument, not a change to the generic itself.
  • Arrow functions with generics need care in .tsx files. const identity = <T>(x: T) => x; can be parsed as a JSX opening tag inside a .tsx file. The common workaround is a trailing comma — <T,>(x: T) => x — or naming a constraint explicitly, <T extends unknown>(x: T) => x.
  • Conditional types distribute over unions unless you stop them. type ToArray<T> = T extends any ? T[] : never; applied to string | number produces string[] | number[], not (string | number)[], because a naked type parameter in a conditional type distributes across each union member individually. Wrapping both sides in a tuple ([T] extends [any] ? ...) disables that distribution when you don't want it.
  • A constraint is a floor, not the resolved type. function f<T extends { id: string }>(x: T) still infers the caller's full type for T (every extra property survives), not just { id: string } — the constraint only restricts which types are legal, it doesn't narrow what gets stored in T.

Best practices: when (not) to reach for generics

Reach for a generic when a function or type's inputs and outputs are related and that relationship needs to survive into the caller's code — a firstElement<T> that returns the same T it received, a Box<T> that wraps any payload, an ApiResponse<T> reused across every endpoint.

Don't reach for a generic when a union type says what you mean more simply. If a parameter only ever needs to be "light" | "dark", write that union — a <T extends "light" | "dark"> generic adds ceremony without adding safety.

Don't reach for a generic to avoid writing unknown. If a value's type genuinely isn't knowable until you inspect it at runtime (parsed JSON, a catch block's error), the honest type is unknown plus a runtime check — not a type parameter with no way to constrain it, and not any, which is how you end up back at the firstElement bug from the top of this article. TypeScript's own satisfies operator solves a related but different problem — keeping a literal's narrow type while still validating it against a shape — worth knowing alongside generics, not instead of them.

Don't over-parameterize. A function with four type parameters, three of them defaulted and never varied at any call site in your codebase, is a sign the abstraction arrived before the second real use case did. Write the concrete version first; generalize when a second caller actually needs a different type.

FAQ

What is a generic in TypeScript?

A generic is a type parameter — a placeholder for a type, written in angle brackets like <T> — that lets a function, interface, type, or class work with any type while the compiler still tracks and checks that type through every use, instead of collapsing everything to any.

Is T extends SomeType the same as class inheritance?

No. In a generic constraint, extends means "must be assignable to" or "must have at least this shape" — it restricts which types are legal for T, not a class hierarchy. T extends { id: string } accepts any type with an id: string property, not just subclasses of some { id: string } class.

Can I use a default value for a generic type parameter?

Yes — interface Box<T = unknown> gives T a default the same way a function parameter can have one. Callers that don't specify a type argument get the default; callers that do (Box<number>) override it.

Is any the same as a generic?

No, and this is the most common confusion. any disables type checking entirely for that value — TypeScript stops tracking it. A generic T keeps type checking fully active; it just defers which type until the call site, and enforces that the same T is used consistently everywhere it appears.

Do generic types exist at runtime?

No. TypeScript's type system, including all generics, is erased during compilation — there is no T in the emitted JavaScript, and you cannot inspect it with typeof or instanceof at runtime. Anything you need to know while the program is running must be represented as an actual runtime value, not just a type parameter.

Cheat sheet

Task Syntax Notes
Generic function function f<T>(x: T): T T inferred from the argument
Generic interface/type interface Box<T> { value: T } Instantiate as Box<number>
Multiple type parameters function zip<A, B>(a: A[], b: B[]) Each inferred independently
Constrain a type parameter <T extends HasLength> "Must be assignable to," not inheritance
Require a real key of an object <T, K extends keyof T> Pairs with indexed access: T[K]
Default type parameter <T = unknown> Used when the caller omits a type argument
Generic class class Stack<T> { push(item: T): void } T fixed once, at new Stack<number>()
Explicit type argument (rare) identity<string>("hi") Usually unnecessary — inference handles it
// The whole pattern, copy-paste ready
interface ApiResponse<TData = unknown> {
  data: TData;
  error: string | null;
}

function firstMatching<T, K extends keyof T>(
  items: T[],
  key: K,
  value: T[K]
): T | undefined {
  return items.find((item) => item[key] === value);
}

class Cache<T> {
  private store = new Map<string, T>();
  set(key: string, value: T): void { this.store.set(key, value); }
  get(key: string): T | undefined { return this.store.get(key); }
}

// usage — every T flows through, checked, with no `any` anywhere
const cache = new Cache<ApiResponse<{ id: string }>>();
Enter fullscreen mode Exit fullscreen mode

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Key takeaways

  • A type parameter is a function argument for types — the compiler substitutes it at the call site and checks the relationship stays consistent, the same way it checks a value argument.
  • extends in a generic constraint means "must be assignable to," not class inheritance — it narrows which types are legal without collapsing T to one fixed type.
  • Reach for a generic when an input and an output (or two inputs) are related and that relationship must survive into the caller's code; reach for a plain union or unknown when it doesn't.
  • Generics are erased at compile time — there's no T left at runtime, so runtime type decisions need an actual value, not a type parameter.
  • any and a generic solve opposite problems: any turns off checking, a generic keeps it on while deferring which type.

You started this article maintaining four near-identical functions, one per type, or one function typed any that quietly let a string crash on .toUpperCase(). Now you have the one mechanism — a type parameter, substituted and checked at every call site — that replaces both: a single firstElement<T>, a single Cache<T>, a single ApiResponse<T>, each one exactly as safe as if you'd hand-written it for every type you'll ever call it with. Where's the first place in your own codebase you'll delete a copy-pasted function and replace it with one generic? Tell me in the comments.

Top comments (2)

Collapse
 
nazar-boyko profile image
Nazar Boyko

One more for the gotchas list: NoInfer, built in since TypeScript 5.4. It stops one position from feeding inference, which fixes the classic case where a default or fallback argument widens T when you wanted it inferred from the main argument only.

Collapse
 
thundergod profile image
HTMLCoder

Too much knowledge in one post