TypeScript Generics: The Complete Guide (with Cheat Sheet)
DEV Community

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
  • The mental model
  • Stage 1: your first generic function
  • Stage 2: generic interfaces and types
  • Stage 3: constraining a type parameter with extends
  • Stage 4: default type parameters
  • Stage 5: multiple type parameters and generic classes
  • Edge cases and gotchas
  • Best practices
  • FAQ
  • Cheat sheet
  • Key takeaways

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

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

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

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

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

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[]

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

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 >> {
  // ...
}

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'
}

The compiler is right to reject this: nothing says T has a .length. The fix is a constraint - extends 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

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 }

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 ),
};

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"]]

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

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, or class work with a variety of types while preserving the relationship between input and output. The compiler substitutes the actual type at each call site and checks consistency.

How do I constrain a generic?
Use the extends keyword after the type parameter: <T extends SomeType>. This restricts T to types that are assignable to SomeType, while still allowing the caller’s specific type to be inferred.

Can I have default type parameters?
Yes. Write <T = DefaultType>. The default is used when the caller does not explicitly supply a type argument.

What is the difference between T extends X and T = X?
T extends X is a constraint - it says T must be assignable to X, but the caller can still pass any subtype. T = X is a default - it provides a fallback when no type argument is given, but the caller can override it with any type (subject to any constraint).

Why does typeof T not work at runtime?
Generics are erased during compilation. The emitted JavaScript contains no trace of the type parameter. If you need to know the type at runtime, pass it as a value (e.g., a constructor or a string tag).

Cheat sheet

(The original article includes a cheat sheet - please refer to the full guide for the visual reference.)

Key takeaways

  • Generics let you write reusable functions, interfaces, and classes that preserve type relationships across different call sites.
  • The mental model: a type parameter is a placeholder for a type, substituted by the compiler just like a function parameter is substituted by a value.
  • Constrain type parameters with extends to guarantee certain properties or behaviors while still permitting variation.
  • Default type parameters reduce boilerplate when most callers don’t need to specify every generic slot.
  • Multiple type parameters are common for pairs like key/value or input/output.
  • Generics are erased at runtime - plan accordingly if you need runtime type information.

Comments

No comments yet. Start the discussion.