Mapped types and conditional types in TypeScript

1. Overview

In TypeScript projects, you often need to write many similar types: Order, then ReadonlyOrder, OrderUpdate, OrderGetters… Writing them manually is tedious and error-prone when Order changes.

Mapped types and conditional types solve exactly this problem. They let you generate new types from existing types. After reading this article, you’ll be able to write utility types like Partial, PickByType, UnwrapPromise. You’ll also know how to test them and when to stop.

2. Setup

You need TypeScript 5.x with strict enabled. The article uses Vitest for type testing:

npm install -D typescript vitest
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

strict: true also enables strictNullChecks. Some results in this article, like whether undefined matches string, depend on this flag.

Examples use this shared type:

export interface Order {
  id: string;
  customerId: string;
  total: number;
  paid: boolean;
  note?: string;
}

3. Mapped types

3.1. Basic syntax

A mapped type iterates over a set of keys and creates a property for each key:

type ReadonlyOrder = {
  readonly [K in keyof Order]: Order[K];
};

const order: ReadonlyOrder = {
  id: "o-1",
  customerId: "c-1",
  total: 250_000,
  paid: false,
};

order.paid = true; // Compile error

Explanation:

  • keyof Order is the union "id" | "customerId" | "total" | "paid" | "note".
  • [K in ...] iterates over each key in that union.
  • Order[K] is an indexed access type. It gets the type of property K.
  • A mapped type in the form [K in keyof T] is called homomorphic. This form preserves the original modifiers, so note remains optional.

The last assignment line produces an error:

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

The example above is tied to Order. To reuse it with any type, add a generic parameter:

type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };

type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }

This is exactly how the standard library defines Readonly<T> and Partial<T>.

3.2. Adding and removing modifiers

You can put + or - before readonly and ?. The + is the default and usually omitted. The - is used to remove modifiers:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type CompleteOrder = Concrete<Order>;

const draft: CompleteOrder = {
  id: "o-2",
  customerId: "c-9",
  total: 99_000,
  paid: true,
  note: "Deliver during business hours",
};

With Concrete<Order>, the note property becomes required. Its type is string, not string | undefined. The standard library has Required<T> that does exactly this.

3.3. Renaming keys with as

Since TypeScript 4.1, you can rename keys with an as clause. This usually goes with template literal types:

interface Customer {
  id: string;
  name: string;
  email: string;
}

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

type CustomerGetters = Getters<Customer>;
// {
//   getId: () => string;
//   getName: () => string;
//   getEmail: () => string;
// }

The string & K part is required. keyof T can include number and symbol, but Capitalize only accepts string. The intersection removes non-string keys.

You can also use as to filter keys. If the expression returns never, that key is excluded from the result:

type WithoutId<T> = {
  [K in keyof T as Exclude<K, "id">]: T[K];
};

type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }

Exclude here is a conditional type. That’s the topic of the next section.

4. Conditional types

4.1. The extends ? : syntax

A conditional type works like a ternary operator, but 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 means “Is T assignable to string?”. This is not class inheritance.

4.2. Distributivity

When T is a naked generic parameter and you pass a union, TypeScript applies the condition to each member of the union:

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

type C = ToArray<string | number>;        // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]

The two results are completely different. Wrapping T in a tuple [T] disables distributivity.

Distributivity is the foundation of Exclude and Extract:

type MyExclude<T, U> = T extends U ? never : T;

type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"

TypeScript examines each member. "cancelled" becomes never, and never disappears from the union.

Note: A distributive conditional type that receives never returns never, because never is an empty union. For example, ToArray<never> is never, not never[]. If you need to detect never, use the tuple-wrapped form: [T] extends [never] ? ... : ....

4.3. Extracting types with infer

infer lets you “capture” part of a type in the true branch of a condition:

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

async function fetchOrder(id: string): Promise<Order> {
  return { id, customerId: "c-1", total: 0, paid: false };
}

type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>;   // Order
type Item = ElementOf<Order[]>;                    // Order

What each part means:

  • Promise<infer U> declares a type variable U and lets TypeScript infer its value.
  • If it matches, the true branch can use U. If not, UnwrapPromise returns T unchanged.
  • ReturnType and Parameters in the standard library are also written with infer.

In practice, you should use the built-in Awaited<T>. It handles nested Promises like Promise<Promise<Order>>.

5. Combining both techniques

5.1. Selecting properties by value type

Put a conditional type in the as clause, and you can filter keys by the type of their value:

type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }

Notice that note doesn’t appear in OrderStrings. The type of Order["note"] is string | undefined, and this doesn’t extend string with strictNullChecks on. Here T[K] is not a naked parameter, so the condition doesn’t distribute. The union is treated as a whole.

5.2. Handlers for strongly-typed events

This is closer to real code. You have a table of order events and want to generate types for handlers:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; paidAt: Date };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};

const handlers: EventHandlers<OrderEvents> = {
  onCreated: (p) => console.log(`Created order ${p.orderId}: ${p.total}`),
  onPaid: (p) => console.log(`Paid at ${p.paidAt.toISOString()}`),
  onCancelled: (p) => console.log(`Cancelled because: ${p.reason}`),
};

handlers.onCreated({ orderId: "o-1", total: 250_000 });
Created order o-1: 250000

The parameter p in each handler gets the right type inferred, so you don’t have to declare it. When you add a refunded event to OrderEvents, TypeScript immediately reports an error because handlers is missing onRefunded.

5.3. Runtime implementation for generated types

Mapped types exist only at compile time. To have a real object, you still need to write the logic:

export function toGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key of Object.keys(obj) as (keyof T & string)[]) {
    const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    result[name] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

Why the type cast? TypeScript can’t prove that the runtime loop creates the exact shape Getters<T>. The as unknown as cast is where you commit to that. This code must have tests.

6. Testing types with Vitest

Vitest has expectTypeOf for checking types, along with regular runtime assertions:

import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";

describe("utility types", () => {
  it("PickByType keeps only matching properties", () => {
    expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
    expectTypeOf<PickByType<Order, string>>()
      .toEqualTypeOf<{ id: string; customerId: string }>();
  });

  it("UnwrapPromise unwraps one Promise layer", () => {
    expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  it("toGetters returns correct getter values", () => {
    const getters = toGetters({ id: "c-1", name: "Alice", email: "alice@example.com" });

    expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
    expect(getters.getName()).toBe("Alice");
    expect(getters.getEmail()).toBe("alice@example.com");

    // @ts-expect-error a non-existent getter must be a compile error
    getters.getPhone;
  });
});

Run tests with type checking:

npx vitest run --typecheck

Note these points:

  • At runtime, expectTypeOf does nothing. The real check happens when the compiler analyzes the file. That’s why you need --typecheck, or run npx tsc --noEmit separately in CI.
  • // @ts-expect-error is for negative tests. If the line below stops producing an error—say, someone makes the type looser—the directive itself becomes an error.
  • Runtime tests for toGetters catch cases where runtime logic drifts from the committed type.

7. Common errors and when not to use

Forgetting string & K when using template literals. Writing `get${Capitalize<K>}` gives an error, because K can be number | symbol.

Not watching out for distributivity. You write IsString<string | number> expecting false, but get boolean (meaning true | false). To treat the union as a block, wrap it in a tuple.

Confusing extends with any. any makes a condition return the union of both branches. For example, IsString<any> is boolean.

Losing modifiers with non-homomorphic mapping. A mapped type [K in "id" | "total"]: Order[K] doesn’t preserve readonly or ? from Order. Only the [K in keyof T] form preserves modifiers. If you need to pick some keys while keeping modifiers, use Pick.

Recursion too deep. Types like DeepPartial<T> applying recursion to nested objects can cause Type instantiation is excessively deep and possibly infinite. It also noticeably slows down editors. Also, Date, Map, and arrays are object, so a condition like T[K] extends object often catches them by mistake.

Note: Don’t use mapped types and conditional types just to “look smart”. If a type is used in one place and rarely changes, a handwritten interface is far more readable. Error messages from types nested 3–4 levels deep are very hard for colleagues to understand. Prefer built-in utilities (Partial, Pick, Omit, Record, Exclude, ReturnType, Awaited) before writing your own.

Believing that types protect runtime. Mapped types don’t validate data from APIs. If JSON returns missing fields, Getters<Customer> still looks “correct” at compile time but is wrong at runtime. With data from outside, add runtime validation.

8. Conclusion

This article introduced two core tools for generating types in TypeScript:

  • Mapped types, with [K in keyof T], let you iterate keys, add or remove readonly/?, rename keys, and filter with as.
  • Conditional types, with T extends U ? X : Y, let you branch at the type level. They distribute over unions and extract subtypes with infer.

When you combine both techniques, you write types like PickByType or EventHandlers. These types self-update when the source type changes. Always include type tests using expectTypeOf and @ts-expect-error. Run --typecheck in CI. Keep these types simple enough for others to understand.

typescriptmapped typesconditional typesgenericstype system