TypeScript Basics #6: Generics in Depth

4 min read

Last time we covered function types and had our first encounter with generics. This time we go one step further and unlock the real expressive power of generics — constraints, multiple type parameters, generic interfaces/classes, keyof, and indexed access types.

This post may feel difficult at first. You don’t need to memorize every pattern in one pass — just getting a sense that these tools exist is enough.

Constraint recap — extends #

We briefly saw constraints in #5. They are the tool for placing requirements on a generic type.

Constraint — only types with length
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}

logLength('hello');       // ✓
logLength([1, 2, 3]);     // ✓
logLength(42);            // 🚫

T extends Shape means “T must be compatible with that shape.” It lets you safely write function-body code that depends on that shape.

Multiple type parameters #

When several type variables appear together, you can express relationships between them.

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

const user = { id: 'u-1', name: '철수', age: 30 };

const id = getProperty(user, 'id');       // string
const age = getProperty(user, 'age');     // number
const bad = getProperty(user, 'foo');     // 🚫 'foo' is not in user

Two key concepts are at work here.

keyof T #

keyof T is the union of all key names of T.

type User = { id: string; name: string; age: number };

type UserKeys = keyof User;   // 'id' | 'name' | 'age'

It produces a literal type union automatically (as in #4). It’s the core tool for working with an object’s keys at the type level.

Indexed access type — T[K] #

T[K] is “the type of the value at key K of type T.”

type User = { id: string; name: string; age: number };

type IdType = User['id'];       // string
type NameType = User['name'];   // string
type AgeType = User['age'];     // number

You can use the same access syntax as JavaScript (user.id, user['id']) at the type level.

Here’s what happens in getProperty<T, K extends keyof T>:

  1. The caller invokes getProperty(user, 'id')
  2. T is inferred as the type of user (User)
  3. K is inferred as 'id'
  4. The return type T[K] resolves to User['id'] = string

Because the type variables are linked, the return type of getProperty(user, 'id') is exactly string. Passing a bad key ('foo') violates the K extends keyof T constraint and produces a compile error.

This is the kind of expressive power that TypeScript offers and JavaScript cannot.

Generic interfaces and type aliases #

Not just functions — type aliases can be generic too.

Generic type alias
type Box<T> = {
  value: T;
};

const a: Box<number> = { value: 42 };
const b: Box<string> = { value: 'hello' };

Box is a general container that holds any type. At the use site you fill in the type — Box<number> — to specialize it.

The same goes for interfaces:

Generic interface
interface ApiResponse<T> {
  data: T;
  status: number;
  timestamp: number;
}

const userResp: ApiResponse<User> = {
  data: { id: 'u-1', name: '철수' },
  status: 200,
  timestamp: Date.now(),
};

This pattern is used heavily for API response wrappers, result containers, and similar structures.

Discriminated unions made generic #

We can generalize the async-state pattern from #4 with generics:

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

function divide(a: number, b: number): Result<number> {
  if (b === 0) return { ok: false, error: '0으로 나눌 수 없음' };
  return { ok: true, value: a / b };
}

const r = divide(10, 2);
if (r.ok) {
  console.log(r.value);   // r.value: number
} else {
  console.log(r.error);   // r.error: string
}

Result<T, E> takes two parameters — T (the success value type) and E (the error type, defaulting to string). The caller fills in the appropriate types.

This pattern is a type-safe error-handling style inspired by languages like Rust. Instead of throwing, you return a result object. It’s popular in large codebases.

Generic classes #

Classes can also be generic.

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 numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop();   // top: number | undefined

const strStack = new Stack<string>();
strStack.push('hello');

The same Stack class can hold numbers, strings, or any other type. T is fixed when the instance is created.

Implementing data structures (Stack, Queue, LinkedList) is one of the most natural uses of generic classes.

Conditional types — a quick taste #

A surprising feature of TypeScript: you can write conditionals at the type level.

conditional types
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;   // true
type B = IsString<42>;         // false

T extends string ? true : false means “the type true if T is a subtype of string, otherwise the type false.”

A practical example:

Extracting an array's element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type A = ArrayElement<number[]>;       // number
type B = ArrayElement<string[]>;       // string
type C = ArrayElement<{ x: 1 }[]>;     // { x: 1 }

infer U means “capture the matched type under the name U.” If T is an array type (U[]), it extracts the element type as U.

Conditional types and infer are TypeScript’s metaprogramming territory. They’re used most by library authors and people who build their own type utilities. You won’t need them often in day-to-day work, but they’re powerful tools to know about.

Mapped types #

Mapped types transform each property of an existing type into a new type.

Mapped type
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

[K in keyof User] means “for each key K of User.” For each property it adds readonly, producing a type where every property of User is readonly.

TypeScript recognizes that patterns like this are common, and ships built-in utility types for the most frequent transforms.

Utility type preview
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;     // every field optional
type RequiredUser = Required<User>;   // every optional becomes required

Readonly<T> and Partial<T> are themselves defined as mapped types. We cover their definitions in #7.

A collection of practical patterns #

1. Union of an object’s value types #

Value-type union
const ROLES = {
  ADMIN: 'admin',
  EDITOR: 'editor',
  VIEWER: 'viewer',
} as const;

type Role = typeof ROLES[keyof typeof ROLES];
// 'admin' | 'editor' | 'viewer'

typeof ROLES is the type of the ROLES object, keyof typeof ROLES gives its keys ('ADMIN' | 'EDITOR' | 'VIEWER'), and the indexed access produces a union of the values.

A very powerful pattern — keep data in one place and derive types from it automatically, with no data-type sync burden.

2. Extracting a function’s return type #

Using ReturnType
function fetchUser() {
  return { id: 'u-1', name: '철수', email: 'cheolsu@example.com' };
}

type User = ReturnType<typeof fetchUser>;
// { id: string; name: string; email: string }

This extracts the shape of a function’s return value into a named type. Without defining a separate type, you pull it automatically from the function signature.

3. Safe object key transformation #

Object key transform
function transform<T extends Record<string, any>, U>(
  obj: T,
  fn: <K extends keyof T>(value: T[K], key: K) => U
): Record<keyof T, U> {
  const result = {} as Record<keyof T, U>;
  for (const key in obj) {
    result[key] = fn(obj[key], key);
  }
  return result;
}

This is a generalized object-transform function. The types are a bit involved, but it’s a great combined example of generics + constraints + keyof + indexed access + Record. You don’t need to fully understand every part right away.

Common pitfalls #

1. Too many generics #

🚫 Excessive generics
function add<A extends number, B extends number>(a: A, b: B): number {
  return a + b;
}

It’s not meaningful for A and B to be subtypes of number. function add(a: number, b: number) is enough. Use generics only when you need to generalize across multiple types.

2. Two meanings of extends #

type IsString<T> = T extends string ? true : false;   // conditional type
function log<T extends string>(value: T): void {}     // constraint

Same keyword, two different meanings depending on position:

  • Next to a generic parameter (<T extends ...>): a constraint
  • Inside a conditional type (T extends ... ? ... : ...): a conditional check

You can tell them apart by their syntactic position.

3. Confusing any with unknown #

When working with a value of unknown type, which should you use?

  • any — checks disabled. Dangerous. Avoid when possible.
  • unknown — checks enforced. Safe. Requires narrowing before use.
  • T (generic) — the caller decides the type; the function preserves it.

For functions that pass data through unchanged, a generic is almost always the right answer. With any or unknown, the caller loses type information.

Compare
function bad(value: any): any { return value; }
function good<T>(value: T): T { return value; }

const a = bad(42);    // a: any (type info lost)
const b = good(42);   // b: number (type info preserved)

Try it yourself — a mini EventEmitter #

A small example showing off the expressive power of generics: an EventEmitter that maps event names to payload types.

event-emitter.ts
type EventMap = {
  click: { x: number; y: number };
  hover: { id: string };
  submit: { values: Record<string, string> };
};

type Listener<T> = (payload: T) => void;

class TypedEmitter<TEvents extends Record<string, any>> {
  private listeners: { [K in keyof TEvents]?: Listener<TEvents[K]>[] } = {};

  on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event]!.push(listener);
  }

  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    this.listeners[event]?.forEach(l => l(payload));
  }
}

const emitter = new TypedEmitter<EventMap>();

emitter.on('click', payload => {
  console.log(payload.x, payload.y);   // payload is automatically { x, y } typed
});

emitter.on('hover', payload => {
  console.log(payload.id);             // payload is { id } typed
});

emitter.emit('click', { x: 10, y: 20 });   // ✓
emitter.emit('click', { id: 'u-1' });       // 🚫 type doesn't match
emitter.emit('xxxxx', {});                  // 🚫 unregistered event

Once event names and their payload types are defined in EventMap, every on and emit call is automatically checked against that definition. Emitting the wrong payload is a compile error.

What you used to express in JavaScript with code and comments is now enforced by the type system. That’s the appeal of TypeScript.

Wrap-up #

This post covered the deeper tools of generics.

  • Constraints (extends) — placing conditions on a type variable
  • Multiple type parameters — expressing relationships between variables
  • keyof T — the union type of an object’s keys
  • Indexed access T[K] — the value type at a specific key of an object
  • Generic type alias / interface / class — putting variables into type shapes
  • Conditional types and infer — type-level pattern matching (a quick taste)
  • Mapped types — transforming each property of an object

Getting this far means you now have nearly all of TypeScript’s expressive power at your fingertips. One more post to go. In “TypeScript Basics #7: Utility Types and tsconfig” we’ll round up the standard utility types like Partial, Pick, Omit, and ReturnType (which we touched on briefly here), and cover the key tsconfig.json options that control compile behavior — wrapping up the series.

X