TypeScript Generics Introduction

Generics let you write functions, classes, and interfaces that work with a variety of types while still keeping full type safety. Instead of writing the same function once per type, or giving up on type checking with any, you write the code once using a placeholder for the type — a type parameter — that TypeScript fills in every time the code is used. The compiler tracks that placeholder through the entire function or class, so you still get accurate autocomplete, correct return types, and compile-time errors when something doesn’t line up. Generics are the backbone of most well-typed TypeScript code, including built-in types like arrays, Promise, and Map.

Overview / How Generics Work

Think of a type parameter the way you think of a function parameter, except instead of standing in for a runtime value, it stands in for a type. A regular function parameter lets you defer choosing a value until the function is called; a type parameter lets you defer choosing a type until the function (or class, or interface) is used.

Without generics you have two bad options when you want a function to work with more than one type. You can write it once per type (numberIdentity, stringIdentity, booleanIdentity…), which is repetitive and doesn’t scale. Or you can type the parameter as any, which compiles but throws away all type checking — TypeScript will happily let you call .toUpperCase() on a number and only find out at runtime that it crashes.

Generics solve this by letting the caller decide the type, while the function body stays generic. TypeScript’s type checker treats the type parameter (conventionally named T) as an unknown-but-fixed type for the duration of that call. Whatever comes in as T is what comes out as T — the compiler enforces that relationship structurally, without you ever writing a type-specific version of the function.

Most of the time you don’t even have to specify the type explicitly. TypeScript uses type inference to figure out T from the arguments you pass, the same way it infers the type of a const from its initializer. You only need to supply the type argument explicitly when there’s nothing for the compiler to infer from, or when the inferred type isn’t the one you actually want.

Syntax

A type parameter is declared in angle brackets <> immediately after the name of the function, interface, class, or type alias, and can then be used anywhere in that declaration as if it were a normal type.

function functionName<T>(param: T): T {
  // T can be used anywhere inside
}
Part Meaning
<T> Declares a type parameter named T. It behaves like a variable, but for types.
T (single letter) Convention, not a rule. Common names: T (Type), K (Key), V (Value), E (Element). Use descriptive names like TItem in complex generics.
<T, U> Multiple type parameters, comma-separated, used when two or more independent types are involved.
identity<number>(5) Explicitly supplying the type argument at the call site.
identity(5) Letting TypeScript infer the type argument from the value 5.

Generics aren’t limited to functions. Interfaces, type aliases, and classes can all declare type parameters:

interface Box<T> {
  value: T;
}

type Pair<T, U> = {
  first: T;
  second: U;
};

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

Examples

Example 1: A generic identity function

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

const num = identity<number>(42);
const str = identity("hello");

console.log(num);
console.log(str);

Output:

42
hello

The first call explicitly supplies <number>, so T is locked to number and TypeScript checks that 42 matches. The second call supplies no type argument at all — TypeScript infers T as string from the argument "hello", so str is typed as string without you writing it out.

Example 2: A generic function over arrays

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

const firstNum = firstElement([10, 20, 30]);
const firstStr = firstElement(["a", "b", "c"]);

console.log(firstNum);
console.log(firstStr);

Output:

10
a

Here T is inferred from the element type of the array argument, not the array itself. Passing number[] makes T resolve to number, so firstNum is typed number | undefined (the undefined comes from the possibility of an empty array). Passing string[] makes T resolve to string. One function definition, two fully type-safe call sites.

Example 3: A realistic generic wrapper type

interface ApiResponse<T> {
  data: T;
  success: boolean;
  timestamp: number;
}

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

function wrapResponse<T>(data: T): ApiResponse<T> {
  return {
    data,
    success: true,
    timestamp: 1690000000000
  };
}

const userResponse = wrapResponse<User>({ id: 1, name: "Ada Lovelace" });
console.log(userResponse.data.name);
console.log(userResponse.success);

const numbersResponse = wrapResponse<number[]>([1, 2, 3]);
console.log(numbersResponse.data);

Output:

Ada Lovelace
true
[ 1, 2, 3 ]

This is closer to real-world usage: a single ApiResponse<T> interface describes the shape of every API response in an app, regardless of what kind of data it wraps. wrapResponse<User> produces an ApiResponse<User>, so userResponse.data is known to have a .name property with full autocomplete. wrapResponse<number[]> produces an ApiResponse<number[]> with no extra code written.

How It Works Step by Step (Under the Hood)

  • Declaration. When you write function identity<T>(value: T): T, TypeScript records that this function has one type parameter, T, and that its parameter and return type both reference T.
  • Instantiation. At each call site, TypeScript either takes the type argument you gave explicitly (identity<number>(...)) or infers it from the argument’s type (identity("hello") infers T = string). This process is called generic instantiation — conceptually, TypeScript substitutes the concrete type for every occurrence of T and checks the call as if that specific version of the function existed.
  • Structural checking. TypeScript’s type system is structural, not nominal — it doesn’t care what a type is named, only what shape it has. So a generic constraint or parameter is satisfied by any value with a matching structure, not just values of a specific class.
  • Inference sources. Inference can pull T from function arguments, from array elements, from object properties, or even from context (for example, the expected return type). When multiple arguments could imply different types for the same T, TypeScript computes the best common type, or reports an error if there isn’t one.
  • Erasure at compile time. Generics exist only in the type system. Once tsc finishes checking your code, all type parameters, type annotations, and type arguments are stripped out — the compiled JavaScript contains a completely ordinary function with no trace of T. This is why generics can’t be used to make runtime decisions (like if (T === string)); by the time the code runs, that information is gone.

Common Mistakes

Mistake 1: Returning a value that doesn’t actually match T

A generic type parameter is a promise to the caller: whatever type goes in as T comes out as T. Breaking that promise is a type error, not just bad style.

function wrapValue<T>(value: T): T {
  return "always a string";
}

This fails with something like Type 'string' is not assignable to type 'T'. — the compiler has no guarantee that T is string for every call, so it refuses to let you hard-code a string as the return value. The fix is to actually use the type parameter:

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

console.log(wrapValue(42));
console.log(wrapValue("hello"));

Output:

42
hello

Mistake 2: Letting inference widen T more than you intended

When a generic function’s inference source is an array or object literal with mixed types, T can be inferred as a union that’s wider than what you actually meant to work with.

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

const mixed = firstElement([1, "two", 3]);
console.log(mixed.toFixed(2));

Here T is inferred as string | number because the array literal mixes both types. That makes mixed a string | number, and tsc reports Property 'toFixed' does not exist on type 'string | number'. because toFixed only exists on number. The fix is to keep the array’s element type consistent (or explicitly annotate the intended type) so T resolves to a single concrete type:

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

const numbers = firstElement([1, 2, 3]);
console.log(numbers.toFixed(2));

Output:

1.00

Best Practices

  • Let TypeScript infer type arguments whenever possible; only specify them explicitly (fn<Type>(...)) when inference would guess wrong or has nothing to infer from.
  • Only introduce a type parameter when the type actually varies between calls. If a function only ever deals with string, use string — don’t generalize prematurely.
  • Use descriptive names for non-trivial generics (TItem, TResponse) once you have more than one or two type parameters; single letters get confusing fast in larger signatures.
  • Never reach for any as a substitute for a missing generic — it silently disables type checking for that value everywhere it flows, while a generic keeps full safety.
  • Make sure every type parameter you declare is actually used in the function’s parameters or return type; an unused type parameter is a sign the function doesn’t need to be generic.
  • Prefer generic interfaces and type aliases (like ApiResponse<T>) for shapes that get reused across many concrete types, instead of duplicating near-identical interfaces.

Practice Exercises

  • Write a generic function lastElement<T> that takes an array of type T[] and returns the last element (or undefined for an empty array). Call it with both a number[] and a string[] and confirm the inferred return types are correct.
  • Write a generic interface Pair<T, U> with a first: T and second: U property, then write a function makePair<T, U>(first: T, second: U): Pair<T, U> that constructs one. Try calling it with two different types, e.g. a number and a string.
  • Write a generic class Queue<T> with enqueue(item: T): void and dequeue(): T | undefined methods backed by a private array. Create a Queue<string>, enqueue a few values, and dequeue them to confirm the order and types.

Summary

  • Generics let one function, interface, or class work correctly across many types, without losing type safety the way any does.
  • A type parameter (commonly T) is declared in angle brackets and can be used anywhere a type is expected inside that declaration.
  • TypeScript usually infers type arguments from the values you pass in; you only need to specify them explicitly when inference can’t figure it out.
  • Interfaces, type aliases, and classes can all be generic, not just functions.
  • All generic type information is erased at compile time — the emitted JavaScript has no trace of type parameters, so generics can never affect runtime behavior directly.
  • Watch out for return values that don’t actually match T, and for inference widening T into a union you didn’t intend when array or object literals mix types.