Emmanuel Akinfulubi

Lesson 17 of 1821 min read

Mapped and Conditional Types

Types that compute. The deep end of the language, scoped honestly: worth reading once, worth using rarely.

By the end: Read a mapped or conditional type without flinching, and write a simple one.

Types that compute. This is where TypeScript stops being annotations and becomes a small functional programming language that runs at compile time.

Let me be honest about the scope before we start. This material is worth reading once so that you can understand a library's types, follow an error message that mentions a conditional type, and recognise what a colleague has written. It is worth using rarely. Most application code needs none of it, and a type nobody on your team can read is a liability regardless of how clever it is.

Mapped types

A mapped type walks the keys of one type and produces a new type. The syntax echoes a for...in loop, deliberately.

Rebuilding the utility types from lesson 16

interface User {
  id: number;
  name: string;
  email?: string;
}

// This is how Readonly is implemented. `[K in keyof T]` iterates the keys.
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

// And Partial. The `?` is added to each property.
type MyPartial<T> = { [K in keyof T]?: T[K] };

// And Required. The `-?` removes the optional marker.
type MyRequired<T> = { [K in keyof T]-?: T[K] };

// And Pick, which iterates a subset of keys.
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };

const frozen: MyReadonly<User> = { id: 1, name: 'Ada' };
const patch: MyPartial<User> = { name: 'Grace' };
const complete: MyRequired<User> = { id: 2, name: 'Grace', email: 'g@example.com' };
const picked: MyPick<User, 'id' | 'name'> = { id: 3, name: 'Alan' };

console.log(frozen.name, patch.name, complete.email, picked.id);
console.log('these are the real implementations, not approximations');

Output


      

Removing modifiers, and transforming the value type

interface Frozen {
  readonly id: number;
  readonly tags?: readonly string[];
}

// Strip both modifiers.
type Thaw<T> = { -readonly [K in keyof T]-?: T[K] };

type Mutable = Thaw<Frozen>;

const mutable: Mutable = { id: 1, tags: ['a'] };
mutable.id = 2;
console.log(mutable);

// The value type is an expression, so you can transform it as you go.
type Stringify<T> = { [K in keyof T]: string };
type AllStrings = Stringify<{ a: number; b: boolean }>;

const strings: AllStrings = { a: '1', b: 'true' };
console.log(strings);

// Or wrap it.
type Boxed<T> = { [K in keyof T]: { value: T[K]; touched: boolean } };
const boxed: Boxed<{ name: string }> = { name: { value: 'Ada', touched: false } };
console.log(boxed.name);

Output


      

Key remapping with as

Changing the keys, not just the values

interface User {
  id: number;
  name: string;
  active: boolean;
}

// `as` after the `in` clause rewrites each key.
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

const getters: Getters<User> = {
  getId: () => 1,
  getName: () => 'Ada',
  getActive: () => true,
};

console.log(getters.getId(), getters.getName(), getters.getActive());

// Mapping a key to `never` drops it, which is how you filter properties.
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type Narrowed = OnlyStrings<User>;

const narrowed: Narrowed = { name: 'Grace' };
console.log('only the string-valued key survived:', Object.keys(narrowed));

Output


      

Conditional types

A extends B ? X : Y is a ternary that operates on types. extends here means “is assignable to”, not “inherits from”.

A type that branches

// Reads as: if T is assignable to string, the result is 'text', else 'other'.
type Kind<T> = T extends string ? 'text' : 'other';

const a: Kind<string> = 'text';
const b: Kind<number> = 'other';
console.log(a, b);

// A practical one: unwrap an array, leave anything else alone.
type Element<T> = T extends readonly (infer Item)[] ? Item : T;

const one: Element<string[]> = 'a';
const two: Element<number> = 42;
const three: Element<readonly boolean[]> = true;
console.log(one, two, three);

// Chained conditionals read like an if/else ladder.
type Describe<T> = T extends string
  ? 'string'
  : T extends number
    ? 'number'
    : T extends boolean
      ? 'boolean'
      : T extends unknown[]
        ? 'array'
        : 'object';

const d1: Describe<string> = 'string';
const d2: Describe<number[]> = 'array';
const d3: Describe<{ a: 1 }> = 'object';
console.log(d1, d2, d3);

Output


      

infer

infer declares a type variable inside a conditional and binds it to whatever matched. It is pattern matching, and it is how ReturnType works.

Rebuilding ReturnType, Parameters and Awaited

// If T is a function, capture its return type and use it. Otherwise never.
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;

// Same idea, capturing the parameter tuple instead.
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;

// Recursive, so nested promises unwrap all the way down.
type MyAwaited<T> = T extends Promise<infer Inner> ? MyAwaited<Inner> : T;

function example(a: number, b: string): { ok: boolean } {
  return { ok: a > 0 && b.length > 0 };
}

const returned: MyReturnType<typeof example> = { ok: true };
const params: MyParameters<typeof example> = [1, 'x'];
const awaited: MyAwaited<Promise<Promise<string>>> = 'unwrapped twice';

console.log(returned, params, awaited);

// infer can capture more than one thing at a time.
type Split<T> = T extends `${infer Head}.${infer Tail}` ? [Head, Tail] : [T];

const split: Split<'user.name'> = ['user', 'name'];
const notSplit: Split<'plain'> = ['plain'];
console.log(split, notSplit);

Output


      

Distribution, and the surprise it causes

A conditional type applied to a union distributes over the members automatically. That is usually what you want, and occasionally very much not.

Distribution, demonstrated

// Applied to a union, this runs once per member and unions the results.
type Boxed<T> = T extends unknown ? { value: T } : never;

// So this is { value: string } | { value: number }, not { value: string | number }.
type Either = Boxed<string | number>;

const a: Either = { value: 'text' };
const b: Either = { value: 42 };
console.log(a, b);

// That distribution is how Exclude works.
type MyExclude<T, U> = T extends U ? never : T;
type Quiet = MyExclude<'debug' | 'info' | 'error', 'error'>;

const quiet: Quiet = 'info';
console.log(quiet);

// And Extract, which is the same thing with the branches swapped.
type MyExtract<T, U> = T extends U ? T : never;
type Loud = MyExtract<'debug' | 'info' | 'error', 'error' | 'fatal'>;

const loud: Loud = 'error';
console.log(loud);

Output


      

Switching distribution off, when you need the whole union

// Distributive: asks the question of each member separately.
type IsStringDistributive<T> = T extends string ? true : false;

// Non-distributive: the square brackets stop it, so the question is asked once
// of the entire union.
type IsStringWhole<T> = [T] extends [string] ? true : false;

// The difference is stark on a mixed union.
const distributed: IsStringDistributive<string | number> = true;   // boolean, in fact
const whole: IsStringWhole<string | number> = false;

console.log('distributed over members:', distributed);
console.log('asked of the whole union:', whole);

// A common use: detecting `never`, which distribution otherwise swallows.
type IsNever<T> = [T] extends [never] ? true : false;

const isNever: IsNever<never> = true;
const isNotNever: IsNever<string> = false;
console.log('never detected:', isNever, '| string:', isNotNever);

Output


      

Template literal types

Types built from string patterns

type Method = 'get' | 'post';
type Resource = 'users' | 'orders';

// The cross product, computed. Four members from two unions.
type Route = `/${Resource}`;
type Endpoint = `${Uppercase<Method>} /${Resource}`;

const route: Route = '/users';
const endpoint: Endpoint = 'POST /orders';
console.log(route, endpoint);

// Which means a typo in a route string is a compile error.
const routes: Endpoint[] = ['GET /users', 'POST /users', 'GET /orders', 'POST /orders'];
console.log(routes.length, 'valid endpoints');

// Extracting from a pattern with infer.
type ResourceOf<E> = E extends `${string} /${infer R}` ? R : never;

const extracted: ResourceOf<'GET /users'> = 'users';
console.log('resource extracted from the route:', extracted);

Output


      

A pattern type catches a malformed string

TS2820
type Method = 'get' | 'post';
type Resource = 'users' | 'orders';
type Endpoint = `${Uppercase<Method>} /${Resource}`;

// Lowercase method, and a resource that does not exist.
const bad: Endpoint = 'get /customers';

console.log(bad);

What the compiler says

error TS2820: Type '"get /customers"' is not assignable to type '"GET /orders" | "GET /users" | "POST /orders" | "POST /users"'. Did you mean '"GET /users"'?

Recursion

A deep Readonly, which is what people usually want

// Recurses into nested objects, which plain Readonly does not.
type DeepReadonly<T> = T extends (infer Item)[]
  ? readonly DeepReadonly<Item>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

interface Config {
  name: string;
  server: {
    host: string;
    ports: number[];
  };
}

const config: DeepReadonly<Config> = {
  name: 'app',
  server: { host: 'localhost', ports: [80, 443] },
};

console.log(config.server.host, config.server.ports[0]);
console.log('reading nested values is unaffected');

Output


      

And the nested properties really are protected

TS2540
type DeepReadonly<T> = T extends (infer Item)[]
  ? readonly DeepReadonly<Item>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

interface Config {
  server: { host: string };
}

const config: DeepReadonly<Config> = { server: { host: 'localhost' } };

config.server.host = 'elsewhere';

console.log(config);

What the compiler says

error TS2540: Cannot assign to 'host' because it is a read-only property.

A worked example worth the complexity

To be fair to the material: here is a case where this genuinely earns its place. Typing a path into a nested object, checked, with autocomplete.

Dotted paths into a nested object, type-checked

// Every valid dotted path through T, as a union of string literals.
type Paths<T> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
    }[keyof T & string]
  : never;

// The type of the value at a given path.
type ValueAt<T, P extends string> = P extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? ValueAt<T[Head], Rest>
    : never
  : P extends keyof T
    ? T[P]
    : never;

interface Settings {
  theme: string;
  editor: {
    fontSize: number;
    minimap: { enabled: boolean };
  };
}

function read<T, P extends Paths<T>>(object: T, path: P): ValueAt<T, P> {
  let current: unknown = object;
  for (const key of path.split('.')) {
    current = (current as Record<string, unknown>)[key];
  }
  return current as ValueAt<T, P>;
}

const settings: Settings = {
  theme: 'dark',
  editor: { fontSize: 14, minimap: { enabled: true } },
};

// Each of these returns a precisely typed value.
const theme = read(settings, 'theme');
const size = read(settings, 'editor.fontSize');
const enabled = read(settings, 'editor.minimap.enabled');

console.log(theme.toUpperCase());
console.log(size.toFixed(0));
console.log(enabled === true);

Output


      

And a path that does not exist is rejected

TS2345
type Paths<T> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
    }[keyof T & string]
  : never;

interface Settings {
  editor: { fontSize: number };
}

function read<T, P extends Paths<T>>(object: T, path: P): unknown {
  return path;
}

console.log(read({ editor: { fontSize: 14 } } as Settings, 'editor.fontSizes'));

What the compiler says

error TS2345: Argument of type '"editor.fontSizes"' is not assignable to parameter of type '"editor" | "editor.fontSize"'.

Reading an error that mentions all this

The practical skill, if you never write a mapped type yourself. Errors involving computed types are long, and they have a reliable structure.

A long error with a simple cause

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

interface User {
  id: number;
  name: string;
}

// One handler returns the wrong type, and one key is misspelled.
const getters: Getters<User> = {
  getId: () => 'not a number',
  getName: () => 'Ada',
};

console.log(getters.getName());

What the compiler says

error TS2322: Type 'string' is not assignable to type 'number'.

Your turn

  1. Implement Partial, Readonly and Pick from scratch as mapped types. Confirm they behave the same as the built-ins.
  2. Write a mapped type that makes every property of an object nullable — { [K in keyof T]: T[K] | null } — and use it.
  3. Write a key-remapping type that prefixes every key with on and capitalises it, producing an event-handler shape from a data shape.
  4. Write type Unwrap<T> that removes one layer of Promise, then make it recursive so it removes all of them.
  5. Write type IsArray<T> both distributively and non-distributively, apply both to string | string[], and explain the difference in the results.
  6. Write a template literal type for CSS units — `${number}px` | `${number}%` — and confirm that '10px' is accepted and '10em' is not.
  7. Finally, take something you wrote for one of the earlier exercises and try to express it with a mapped type. Then decide honestly whether the version you had was better.

Recap

  • A mapped type walks the keys of one type to build another. readonly and ? can be added, and -readonly / -? remove them.
  • Key remapping with as rewrites key names; mapping a key to never deletes the property, which is how property filtering works.
  • A conditional type is a ternary over types, where extends means “is assignable to”. infer captures whatever matched.
  • Conditional types distribute over unions automatically. Wrap both sides in tuples — [T] extends [U] — to stop it, which is the fix when a union produces a surprising result.
  • Template literal types compute string patterns, so a malformed route or unit string becomes a compile error.
  • Recursive types work and have a depth limit, and they are the main cause of slow builds and laggy editors.
  • The judgement matters more than the syntax: use this when a shape must track another across many properties, and write it out longhand when it must not.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.