Conditional and Mapped Types

TypeScriptWebType SafetyAdvanced Types
Share on LinkedIn

I was building a type-safe event emitter and needed on('click', handler) to infer the handler's event payload type from the event name string. Without conditional types, I'd have maintained a parallel lookup table by hand — a map of event names to payload types that drifted from reality every time someone added an event. Conditional types with infer let the compiler derive the relationship directly from the type definition. That pattern — inspect a type, extract a piece, transform it — is the backbone of advanced TypeScript, and once it clicks, you stop writing runtime validation that duplicates what the type system already knows.

Conditional types: type-level if/else

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

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

The power comes from combining extends with infer:

// Extract return type of any function
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type R1 = MyReturnType<() => string>;        // string
type R2 = MyReturnType<(x: number) => boolean>; // boolean

This is exactly how TypeScript's built-in ReturnType<T> works.

Distributive conditionals

When the checked type is a naked type parameter, conditional types distribute over unions:

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

type StrOrNumArray = ToArray<string | number>;
// string[] | number[]  (not (string | number)[])

Each union member is evaluated independently. To prevent distribution, wrap in a tuple:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type Combined = ToArrayNonDist<string | number>;
// (string | number)[]

Practical infer patterns

Extract promise inner type:

type Awaited<T> = T extends Promise<infer U> ? U : T;

type Inner = Awaited<Promise<string>>; // string

Extract the first argument of a function:

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

type Arg = FirstArg<(name: string, age: number) => void>; // string

Extract array element type:

type ElementOf<T> = T extends (infer E)[] ? E : never;

type Item = ElementOf<string[]>; // string

Mapped types: transform every key

type Optional<T> = {
  [K in keyof T]?: T[K];
};

type ReadonlyFields<T> = {
  readonly [K in keyof T]: T[K];
};

These are the implementations behind Partial<T> and Readonly<T>.

Key remapping with as

TypeScript 4.1 added key remapping in mapped types:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person { name: string; age: number; }

type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number; }

The as clause can also filter keys:

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Mixed { name: string; age: number; active: boolean; }

type StringFields = OnlyStrings<Mixed>;
// { name: string }

Combining mapped and conditional

A pattern I use constantly — make specific keys required:

type RequireKeys<T, K extends keyof T> = T & {
  [P in K]-?: T[P];
};

interface Config {
  host?: string;
  port?: number;
  timeout?: number;
}

type RequiredConfig = RequireKeys<Config, "host" | "port">;
// host and port are required; timeout stays optional

The -? modifier removes optionality from specific keys.

Building a type-safe API client

Combining both features for an API where route determines response type:

interface Routes {
  "/users": { users: User[] };
  "/users/:id": { user: User };
  "/health": { status: "ok" };
}

type RouteParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof RouteParams<`/${Rest}`>]: string }
    : T extends `${infer _Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

type ResponseFor<TRoute extends keyof Routes> = Routes[TRoute];

async function api<TRoute extends keyof Routes>(
  route: TRoute,
  ...args: keyof RouteParams<TRoute & string> extends never
    ? []
    : [params: RouteParams<TRoute & string>]
): Promise<ResponseFor<TRoute>> {
  // implementation
  return {} as ResponseFor<TRoute>;
}

const users = await api("/users");
//    ^? { users: User[] }

const user = await api("/users/:id", { id: "123" });
//    ^? { user: User }

The return type is inferred from the route string. Params are required only for routes that have them. No manual type assertions at call sites.

Utility types worth knowing

Built-in What it does
Partial<T> All properties optional
Required<T> All properties required
Pick<T, K> Select specific keys
Omit<T, K> Remove specific keys
Record<K, V> Object type with keys K and values V
Exclude<T, U> Remove U from union T
Extract<T, U> Keep only T members assignable to U
NonNullable<T> Remove null and undefined
Parameters<T> Tuple of function parameter types
ReturnType<T> Function return type

Most of these are implemented with conditional and mapped types internally. Reading their source in lib.es5.d.ts is one of the best ways to learn the patterns.

When to stop

Not every type needs to be derived. If a mapped conditional type takes 10 lines to read and you use it once, a plain interface is better. These tools earn their complexity when they eliminate entire categories of runtime bugs across many call sites — API clients, event systems, form validators, and ORM query builders are the sweet spots.

Common production mistakes

Teams get conditional mapped types wrong in predictable ways:

TypeScript patterns for conditional mapped types erode when any escapes during deadlines, generic constraints are loosened instead of modeling domain invariants, and strict mode is disabled file-by-file without a migration plan.

Resources

Frequently asked questions

What is a conditional type in TypeScript?

A conditional type selects one of two types based on a condition, using the syntax T extends U ? X : Y. It works like a ternary operator at the type level. Conditional types can inspect whether a type extends another, extract inner types with infer, and distribute over union members. They are the foundation of most advanced TypeScript utility types like Exclude, Extract, and ReturnType.

What is a mapped type and when do I use one?

A mapped type iterates over the keys of an existing type and transforms each property, using the syntax { [K in keyof T]: ... }. Built-in examples are Partial, Readonly, and Record. You use mapped types when you need to systematically transform every property of a type — making them optional, readonly, nullable, or renaming keys — without manually duplicating the shape.

What does the infer keyword do in conditional types?

The infer keyword declares a type variable inside a conditional type that TypeScript infers from the matched pattern. For example, ReturnType<T> uses infer to extract the return type from a function signature: T extends (...args: any[]) => infer R ? R : never. Without infer, you would need to manually specify types you want to extract, which defeats the purpose of type-level introspection.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →