TypeScript Basics #6: Generics in Depth
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.
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.
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>:
- The caller invokes
getProperty(user, 'id') Tis inferred as the type ofuser(User)Kis inferred as'id'- The return type
T[K]resolves toUser['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.
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:
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:
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.
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.
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:
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.
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.
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 #
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 #
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 #
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 #
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.
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.
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.