TypeScript SeriesPart 4 of 4

Advanced TypeScript — Conditional, Mapped, and Template Literal Types

Feb 10, 2025·4 min read·
TypeScriptAdvanced TypesType System

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>;   // false

The 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>;  // boolean

Built-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 name

The 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.