Advanced TypeScript — Conditional, Mapped, and Template Literal Types
TypeScript's type system isn't just for labelling values — it's a mini programming language in its own right. Conditional types, mapped types, and template literal types let you derive new types from existing ones, which is how libraries like Zod, tRPC, and Prisma achieve their deep type safety.
Discriminated Unions
Before diving into type computation, discriminated unions deserve attention — they're the most practically useful advanced feature.
A discriminated union is a union where each member has a common literal field (the discriminant) that TypeScript uses to narrow the type:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2; // TypeScript knows shape.radius exists
case 'rect':
return shape.width * shape.height;
case 'triangle':
return 0.5 * shape.base * shape.height;
}
}If you add a new shape variant to Shape, TypeScript will flag the switch as non-exhaustive — a compile-time guarantee that your logic stays in sync.
Type Guards
TypeScript narrows types in conditionals automatically (typeof, instanceof), but you can also teach it how to narrow custom types:
interface Cat { kind: 'cat'; meow(): void }
interface Dog { kind: 'dog'; bark(): void }
type Pet = Cat | Dog;
function isCat(pet: Pet): pet is Cat {
return pet.kind === 'cat';
}
function greet(pet: Pet) {
if (isCat(pet)) {
pet.meow(); // TypeScript knows pet is Cat here
} else {
pet.bark(); // TypeScript knows pet is Dog here
}
}The pet is Cat return type is the type predicate — it tells TypeScript what the type becomes when the guard returns true.
Conditional Types
Conditional types let you pick one type or another based on a condition:
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // falseThe real power comes with infer — extracting a type from within another:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<number>; // number (not wrapped, returned as-is)
type UnpackArray<T> = T extends (infer U)[] ? U : T;
type C = UnpackArray<string[]>; // string
type D = UnpackArray<boolean>; // booleanBuilt-in utility types like ReturnType<T> and Parameters<T> are implemented with exactly this pattern:
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
function fetchUser(): Promise<User> { ... }
type Result = ReturnType<typeof fetchUser>; // Promise<User>Mapped Types
Mapped types iterate over the keys of an existing type to create a new one:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};You can combine mapping with conditional types to transform selectively:
type StringifyValues<T> = {
[K in keyof T]: T[K] extends number ? string : T[K];
};
interface Config {
port: number;
host: string;
debug: boolean;
}
type StringifiedConfig = StringifyValues<Config>;
// { port: string; host: string; debug: boolean }Template Literal Types
Template literal types compose string literal types the same way template strings compose values:
type Direction = 'top' | 'right' | 'bottom' | 'left';
type Margin = `margin-${Direction}`;
// 'margin-top' | 'margin-right' | 'margin-bottom' | 'margin-left'This is useful for building strongly-typed event systems or API paths:
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type ApiRoute = `${HttpMethod} ${Endpoint}`;
// 'GET /users' | 'GET /posts' | 'POST /users' | ... (12 combinations)Putting It Together: A Typed Event Emitter
Here's a practical example that combines all these features:
type EventMap = {
userCreated: { id: string; name: string };
userDeleted: { id: string };
orderPlaced: { orderId: string; total: number };
};
type EventName = keyof EventMap;
class TypedEmitter {
private handlers = new Map<string, Set<Function>>();
on<E extends EventName>(
event: E,
handler: (payload: EventMap[E]) => void
): void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
}
emit<E extends EventName>(event: E, payload: EventMap[E]): void {
this.handlers.get(event)?.forEach(h => h(payload));
}
}
const emitter = new TypedEmitter();
// TypeScript knows the payload shape for each event
emitter.on('userCreated', ({ id, name }) => {
console.log(`New user: ${name} (${id})`);
});
emitter.emit('userCreated', { id: '1', name: 'Prabhash' }); // ✓
emitter.emit('userCreated', { id: '1' }); // ✗ missing nameThe type system enforces that each event's payload matches its declared shape — no runtime surprises.
In Part 5, we'll apply TypeScript to React and Next.js — typed props, hooks, server components, and API routes.
TypeScript Series