Skip to content

02 · Generics

Generics let you write a function, class, or type once and have it work correctly for many different types — without falling back to any and losing type safety. If you've ever wondered how Array<T> or Promise<T> work for every possible type without a thousand copy-pasted definitions, this is how.

The problem generics solve

Without generics, you either duplicate code per type or give up type safety with any:

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

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

// Or the "give up" version:
function firstAny(arr: any[]): any {
  return arr[0];
}

const oops = firstAny([1, 2, 3]);
oops.toUpperCase();   // no error at compile time -- but crashes at runtime:
// TypeError: oops.toUpperCase is not a function

any disables type checking entirely for that value — it's the escape hatch, not a type. Every any is a place a real bug can hide until runtime.

Generic functions

A generic function introduces a type parameter (conventionally T) that stands in for "whatever type the caller passes":

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

const num = firstElement([1, 2, 3]);          // inferred as number
const str = firstElement(["a", "b", "c"]);    // inferred as string

console.log(num.toFixed(1));      // 1.0
console.log(str.toUpperCase());   // A

// str.toFixed(1);
// error TS2339: Property 'toFixed' does not exist on type 'string'.

TypeScript infers T from the argument you pass — you rarely need to write it explicitly, though you can: firstElement<number>([1, 2, 3]).

Multiple type parameters

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

const p = pair("age", 30);
console.log(p);   // [ 'age', 30 ]
// p is typed as [string, number] -- a tuple, not just (string | number)[]

Generic constraints

Sometimes T needs to guarantee it has certain properties. extends constrains what types are allowed:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(value: T): T {
  console.log(`Length: ${value.length}`);
  return value;
}

logLength("hello");        // Length: 5
logLength([1, 2, 3]);      // Length: 3
logLength({ length: 10, unit: "cm" });   // Length: 10

// logLength(42);
// error TS2345: Argument of type 'number' is not assignable to
// parameter of type 'HasLength'.

Without the constraint, value.length wouldn't compile at all — plain T guarantees nothing about its shape.

Constraining to a key of another type

A very common pattern: a function that looks up a property, constrained so the key must actually exist on the object:

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

const user = { id: 1, name: "Nina", active: true };

console.log(getProperty(user, "name"));     // Nina
console.log(getProperty(user, "active"));   // true

// getProperty(user, "email");
// error TS2345: Argument of type '"email"' is not assignable to
// parameter of type '"id" | "name" | "active"'.

K extends keyof T is worth memorizing — it's how libraries type-safely model "pick one field off this object" without any or string literals that could typo silently.

Generic classes

Classes can be generic too, letting one implementation serve many element types:

class Box<T> {
  private contents: T;

  constructor(value: T) {
    this.contents = value;
  }

  get(): T {
    return this.contents;
  }

  set(value: T): void {
    this.contents = value;
  }
}

const numberBox = new Box<number>(42);
console.log(numberBox.get());   // 42

const stringBox = new Box("hello");   // T inferred as string
stringBox.set("world");
console.log(stringBox.get());   // world

// numberBox.set("nope");
// error TS2345: Argument of type 'string' is not assignable to
// parameter of type 'number'.

A generic stack

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];
  }

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

const stack = new Stack<string>();
stack.push("first");
stack.push("second");
console.log(stack.peek());   // second
console.log(stack.size);     // 2
console.log(stack.pop());    // second
console.log(stack.size);     // 1

Note pop(): T | undefined — it's tempting to write just T, but that would be a lie: popping an empty stack really does return undefined at runtime, and the type should say so.

Default type parameters

Type parameters can have defaults, just like function parameters:

interface ApiResponse<T = unknown> {
  data: T;
  success: boolean;
}

const withDefault: ApiResponse = { data: "anything", success: true };
const typed: ApiResponse<number> = { data: 42, success: true };

console.log(withDefault.data, typed.data);   // anything 42

Prefer defaulting to unknown rather than anyunknown still forces callers to narrow before using the value, while any waives all checking.

Generic type aliases

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { ok: false, error: "Division by zero" };
  }
  return { ok: true, value: a / b };
}

const r1 = divide(10, 2);
const r2 = divide(10, 0);

if (r1.ok) {
  console.log(r1.value);   // 5
}
if (!r2.ok) {
  console.log(r2.error);   // Division by zero
}

This Result<T, E> pattern shows up constantly once you start typing things like API calls and file parsing — see it again in Module 6 and the weather dashboard project.

A trap: generics don't validate anything at runtime

Generics are a compile-time-only construct — they're erased entirely when TypeScript compiles to JavaScript. A generic function can't check at runtime that the value it receives actually matches T:

function trustinglyCast<T>(value: unknown): T {
  return value as T;   // no runtime check happens here at all
}

interface Cat { meow(): void }

const notActuallyACat = trustinglyCast<Cat>({ bark: () => console.log("Woof") });
// TypeScript believes notActuallyACat is a Cat -- it compiles fine.
// notActuallyACat.meow();
// Runtime error: notActuallyACat.meow is not a function

Generics constrain what the type checker accepts; they do nothing to verify data that genuinely arrives from outside your program (JSON payloads, user input, files). That's the job of runtime validation, covered in Module 8.

How It Actually Works

Generics are resolved through type inference from call-site arguments, a distinct algorithm from the ordinary structural assignability check. When you call identity(5) against function identity<T>(x: T): T, the checker doesn't look up a declared type for T — it collects "inference candidates" by structurally matching the actual argument's type (number, or more precisely the literal 5) against the position where T appears in the parameter's type, then picks the best common supertype among all candidates gathered across every parameter that mentions T. This is why generic inference can fail to pick the type you expect when T appears in multiple parameters with conflicting concrete types — the checker computes one unified T for the whole call, not one per occurrence.

Contextual typing feeds the other direction: when a generic function's type parameter can't be inferred from arguments alone (e.g., an empty array literal [] passed where T[] is expected), the checker instead looks at the expected type from the surrounding context — a variable's declared type, a return-type annotation, another parameter's type in the same call — and uses that to seed T. This two-directional inference (bottom-up from arguments, top-down from context) is why moving the same expression to a different position in your code can change what a generic call infers, even with no other change.

At emit, all of this vanishes without a trace: identity<number>(5) compiles to exactly identity(5), with no runtime representation of T whatsoever — there is no way for the compiled function to know at runtime what T was instantiated as, which is the underlying reason patterns like new T() or x instanceof T inside a generic function body are compile errors: T is erased before that code ever runs, so there's no runtime value to instantiate or check against.

Generic constraints (<T extends { id: number }>) don't change any of this — they only narrow what the checker will accept as T during structural comparison at the call site; they add zero runtime validation, so a value can still fail to genuinely have that shape if it was force-cast (as) into the call.

Cheat sheet

Syntax Meaning
function f<T>(x: T): T Generic function, T inferred from the call site
class Box<T> { ... } Generic class, instantiated as new Box<number>(...)
T extends HasLength Constrain T to types with a length property
K extends keyof T Constrain K to an actual key of T
Box<T = unknown> Default type parameter if none is given
T[] / Array<T> Equivalent generic array syntaxes

Exercise

Write a generic function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] that extracts a single field from every object in an array (e.g. pluck(users, "name") returns string[]). Then write a generic class Queue<T> with enqueue(item: T): void, dequeue(): T | undefined, and a size getter, and prove it works with both a Queue<number> and a Queue<string> in the same file.