Skip to main content
TypeScript · lesson 9 of 21
TypeScript intermediate Lesson 9 of 21

Generics in TypeScript

Generic functions, classes, constraints, defaults, distributive conditional types and infer — each shown with real tsc errors and tsx output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

Why Generics?

A function that works for any type has two options: any, which forgets the type, or a type parameter, which carries it through.

function wrapAny(value: any): { value: any } {
  return { value };
}

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

const a = wrapAny(42);
const b = wrap(42);

console.log(a.value.toUpperCase());
console.log(b.value.toUpperCase());
$ npx tsc --noEmit --module nodenext why.ts
why.ts(13,21): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

Only the generic version catches the bug. wrap(42) inferred T as number from the argument, so the return type is { value: number } — and it’s only an error because the type survived the trip through the function.

Type Argument Inference

You rarely write the type argument; TypeScript infers it from the call. Emitting declarations shows what it chose:

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

export const p1 = pair("hello", 42);
export const p2 = pair(true, ["a"]);
export const p3 = pair<string, number | null>("x", null);
export const empty = pair([], {});
$ npx tsc --declaration --emitDeclarationOnly --outDir types infer-args.ts
$ cat types/infer-args.d.ts
export declare const p1: [string, number];
export declare const p2: [boolean, string[]];
export declare const p3: [string, number | null];
export declare const empty: [never[], {}];

Each call infers its own A and B. p3 shows the explicit form: without <string, number | null> the second element would be typed null alone. And pair([], {}) infers never[] — an array nothing can be added to — which is the classic case where you need an explicit type argument or an annotation.

Constraints

T extends X limits T to types assignable to X, which in turn lets the function use X’s members.

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

console.log(longest("apple", "fig"));
console.log(longest([1, 2], [1, 2, 3]));
console.log(longest(10, 20));

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const users = [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }];
const ages: number[] = pluck(users, "age");
pluck(users, "email");
$ npx tsc --noEmit --module nodenext constraints.ts
constraints.ts(7,21): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
constraints.ts(15,14): error TS2345: Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.

Numbers have no length, and "email" isn’t a key of the user objects. Remove both bad lines, add console.log(ages), and run it:

$ npx tsx constraints.ts
apple
[ 1, 2, 3 ]
[ 30, 25 ]

longest returns the same type it was given — a string for strings, a number[] for arrays — which a parameter typed { length: number } couldn’t do: it would hand back { length: number } and lose everything else.

Generic Interfaces and Classes

A generic class fixes its type parameter when an instance is created, and every method uses it.

interface Stack<T> {
  push(item: T): void;
  pop(): T | undefined;
  readonly size: number;
}

class ArrayStack<T> implements Stack<T> {
  #items: T[] = [];
  push(item: T) { this.#items.push(item); }
  pop() { return this.#items.pop(); }
  get size() { return this.#items.length; }
}

const numbers = new ArrayStack<number>();
numbers.push(1);
numbers.push(2);
console.log(numbers.pop(), numbers.size);

const inferred = new ArrayStack();
inferred.push("anything");
inferred.push(42);
console.log(inferred.size);

numbers.push("3");
$ npx tsc --noEmit --module nodenext repository.ts
repository.ts(24,14): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

numbers is an ArrayStack<number>, so push("3") is rejected. But new ArrayStack() with no type argument and nothing to infer from gets T = unknown — it silently accepts anything. Give generic classes an explicit type argument when the constructor doesn’t mention T.

Default Type Parameters

<T = X> supplies a type when the caller doesn’t give one.

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

const raw: ApiResponse = { data: { name: "Alice" }, status: 200 };
const typed: ApiResponse<{ name: string }> = { data: { name: "Alice" }, status: 200 };

console.log(typed.data.name.toUpperCase());
console.log(raw.data.name);
$ npx tsc --noEmit --module nodenext defaults.ts
defaults.ts(10,13): error TS18046: 'raw.data' is of type 'unknown'.

Defaulting to unknown rather than any means an untyped response is safe by default: you can pass raw.data around, but you have to narrow it before reading name.

Conditional Types and Distribution

A conditional type applied to a bare type parameter distributes over a union — it runs once per member.

type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayOnce<T> = [T] extends [unknown] ? T[] : never;

const a: ToArray<string | number> = [1, "two"];
const b: ToArrayOnce<string | number> = [1, "two"];

type NonNull<T> = T extends null | undefined ? never : T;
const c: NonNull<string | null | undefined> = null;
$ npx tsc --noEmit --module nodenext distribute.ts
distribute.ts(4,7): error TS2322: Type '(string | number)[]' is not assignable to type 'string[] | number[]'.
  Type '(string | number)[]' is not assignable to type 'string[]'.
    Type 'string | number' is not assignable to type 'string'.
      Type 'number' is not assignable to type 'string'.
distribute.ts(8,7): error TS2322: Type 'null' is not assignable to type 'string'.

ToArray<string | number> became string[] | number[], so a mixed array fails. Wrapping both sides in a one-element tuple turns distribution off, giving (string | number)[], which is why b is fine. Distribution is also how filtering works: NonNull maps null and undefined to never, and never disappears from a union, leaving string. That’s exactly the built-in NonNullable<T>.

The infer Keyword

Inside a conditional type, infer X captures part of the matched type.

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type ElementType<T> = T extends readonly (infer E)[] ? E : never;
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;

function parse(input: string) {
  return { ok: true, length: input.length };
}

const r: MyReturnType<typeof parse> = { ok: true, length: "5" };
const e: ElementType<string[][]> = "x";
const u: Unwrap<Promise<Promise<number>>> = "x";
$ npx tsc --noEmit --module nodenext infer.ts
infer.ts(9,51): error TS2322: Type 'string' is not assignable to type 'number'.
infer.ts(10,7): error TS2322: Type 'string' is not assignable to type 'string[]'.
infer.ts(11,7): error TS2322: Type 'string' is not assignable to type 'number'.

MyReturnType<typeof parse> read the return type straight off the function — note typeof, which turns a value into its type. ElementType<string[][]> peels one level, giving string[]. Unwrap recurses until the type isn’t a promise. The standard library ships these as ReturnType, and Awaited.

A Worked Example: A Typed Event Emitter Class

A class generic over an event map links each event name to its payload type.

class TypedEmitter<Events extends Record<string, unknown>> {
  #listeners: { [K in keyof Events]?: Set<(data: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
    (this.#listeners[event] ??= new Set()).add(listener);
    return this;
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.#listeners[event]?.forEach((listener) => listener(data));
  }
}

interface AppEvents {
  userLoggedIn: { userId: string };
  orderPlaced: { orderId: string; total: number };
}

const emitter = new TypedEmitter<AppEvents>();
$ npx tsc --noEmit --module nodenext emitter.ts
emitter.ts(19,34): error TS2344: Type 'AppEvents' does not satisfy the constraint 'Record<string, unknown>'.
  Index signature for type 'string' is missing in type 'AppEvents'.

A surprise: AppEvents has only string keys, yet it doesn’t satisfy Record<string, unknown>. That constraint has an index signature, and an interface is never assumed to have one, because declaration merging could add incompatible members later. Declaring the map with type fixes it:

class TypedEmitter<Events extends Record<string, unknown>> {
  #listeners: { [K in keyof Events]?: Set<(data: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
    (this.#listeners[event] ??= new Set()).add(listener);
    return this;
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.#listeners[event]?.forEach((listener) => listener(data));
  }
}

type AppEvents = {
  userLoggedIn: { userId: string };
  orderPlaced: { orderId: string; total: number };
};

const emitter = new TypedEmitter<AppEvents>()
  .on("userLoggedIn", ({ userId }) => console.log(`user ${userId} logged in`))
  .on("orderPlaced", (o) => console.log(`order ${o.orderId}: ${o.total.toFixed(2)}`));

emitter.emit("userLoggedIn", { userId: "123" });
emitter.emit("orderPlaced", { orderId: "A1", total: 19.5 });
emitter.emit("orderPlaced", { orderId: "A2" });
emitter.on("unknownEvent", () => {});
$ npx tsc --noEmit --module nodenext emitter2.ts
emitter2.ts(25,29): error TS2345: Argument of type '{ orderId: string; }' is not assignable to parameter of type '{ orderId: string; total: number; }'.
  Property 'total' is missing in type '{ orderId: string; }' but required in type '{ orderId: string; total: number; }'.
emitter2.ts(26,12): error TS2345: Argument of type '"unknownEvent"' is not assignable to parameter of type 'keyof AppEvents'.

Returning this makes .on chainable while keeping the full TypedEmitter<AppEvents> type. Remove the two bad calls:

$ npx tsx emitter2.ts
user 123 logged in
order A1: 19.50

Practice

1. Write groupBy<T, K extends PropertyKey>(items: T[], key: (item: T) => K): Record<K, T[]> and group some words by their first letter.

Solution
function groupBy<T, K extends PropertyKey>(items: T[], key: (item: T) => K): Record<K, T[]> {
  const out = {} as Record<K, T[]>;
  for (const item of items) {
    (out[key(item)] ??= []).push(item);
  }
  return out;
}

const byLetter = groupBy(["apple", "avocado", "banana"], (w) => w[0] ?? "");
const byLength = groupBy([1, 22, 333, 44], (n) => (String(n).length > 1 ? "long" : "short"));
console.log(byLetter, byLength.long);
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
{ a: [ 'apple', 'avocado' ], b: [ 'banana' ] } [ 22, 333, 44 ]

PropertyKey is string | number | symbol — anything that can be an object key. In the second call K is inferred as "long" | "short", so byLength.long type-checks and byLength.medium would not. The as is needed because {} doesn’t yet have the keys the return type promises.

2. Give ArrayStack a default so new ArrayStack() without a type argument accepts only strings.

Solution
class ArrayStack<T = string> {
  #items: T[] = [];
  push(item: T) { this.#items.push(item); }
  get size() { return this.#items.length; }
}

const s = new ArrayStack();
s.push("ok");
console.log(s.size);
s.push(42);
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(10,8): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

With nothing to infer from, the default applies instead of unknown.

3. Write type FirstArg<F> that extracts the type of a function’s first parameter, and check it against (id: number, name: string) => void.

Solution
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;

type Handler = (id: number, name: string) => void;
const ok: FirstArg<Handler> = 42;
const bad: FirstArg<Handler> = "42";
const none: FirstArg<() => void> = 1;
console.log(ok, bad, none);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(5,7): error TS2322: Type 'string' is not assignable to type 'number'.

FirstArg<Handler> is number, so only bad is rejected. For a function with no parameters, infer A falls back to unknown, so none accepts anything. If that matters, return never for zero-parameter functions by matching F extends () => any first.

Frequently Asked Questions

What is the point of generics?
Generics let you write code that works across multiple types while preserving type relationships. Instead of losing type information with any, you keep full type safety and IDE support.
What does T extends something do?
It constrains the generic type parameter. T extends string means T can only be a string or string subtype. T extends keyof U means T must be one of U's property keys.
What is the infer keyword?
infer declares a type variable inside a conditional type that TypeScript infers from the matched type. It is how you extract parts of a type — like the return type of a function or the element type of an array.
Do I need to write the type argument when calling a generic function?
Usually not. TypeScript infers type arguments from the values you pass. Write them explicitly, as in identity<string>(value), when there is nothing to infer from, such as an empty array or a function that only uses T in its return type, or when inference picks a narrower type than you want.
Why does my interface not satisfy Record<string, unknown>?
Record<string, unknown> has an index signature, and TypeScript only treats type aliases of object literal types as implicitly having one. An interface could be merged with more properties later, so it is not assumed to fit. Declare the map with type instead of interface, or constrain with an object type that has no index signature.