Type-Level Programming: Mapped and Conditional Types

Everything so far used types to describe values. This lesson uses types to compute other types — a second programming language living in your type definitions, with its own functions (mapped types), conditionals (conditional types), and pattern matching (infer). You already depend on this layer: Partial<T> and Record<K, V> are not builtins, they are ordinary TypeScript code in the standard library's lib.es5.d.ts. By the end you will be able to read their definitions — and write your own.

Input type flows into a mapped-type transformer and a conditional-type transformer, producing a derived output type consumed by the checker
Figure 1 — Type-level computation. A base type flows through type transformers that derive new types at compile time. Nothing here exists at runtime — the whole figure happens inside the checker.

Mapped types: types from types

A mapped type iterates over the keys of one type and produces a new property for each — the map you write in JavaScript, applied to a type's shape.

The mechanics: keyof and the [K in keyof T] syntax

keyof T gives you a type's keys as a union of string literals; the [K in keyof T] clause iterates it.

Purpose of the example: build form-validation state so that forgetting a field is a type error, not a bug report.

interface SignupForm {
  email: string;
  password: string;
  age: number;
}

// For every key K of SignupForm, produce a validation result property.
// The value type is computed once and applied to each field.
type ValidationState<T> = {
  [K in keyof T]: { valid: boolean; message?: string };
};

// The checker derives the shape — all three fields, exactly spelled:
const state: ValidationState<SignupForm> = {
  email: { valid: true },
  password: { valid: false, message: "too short" },
  age: { valid: true },
};

// type ValidationState =
// {
//   email:    { valid: boolean; message?: string };
//   password: { valid: boolean; message?: string };
//   age:      { valid: boolean; message?: string };
// }

What you should see: delete the age property or misspell it, and the checker errors. In JavaScript the same object would drift silently as fields were added; here the derived type tracks SignupForm automatically.

Modifiers and key remapping

Two additions make mapped types expressive: modifiers and remapping.

Purpose of the example: recreate the standard library's Partial and Readonly, then rename keys — the two tricks behind most real-world type utilities.

// lib.es5.d.ts, unabbreviated — you have been using this all along:
type MyPartial<T> = { [K in keyof T]?: T[K] };       // ? adds optionality
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }; // readonly blocks writes

// TS 4.1+: `as` remaps keys while mapping values.
type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
// type Setters = {
//   setEmail:    (value: string) => void;
//   setPassword: (value: string) => void;
//   setAge:      (value: number) => void;
// };

What you should see: hover MyPartial in the editor and expand any standard-library utility to its definition — you will find mapped types like these, not magic. T[K] ("indexed access") reads the value type of key K, the same bracket syntax objects use, at the type level.

Conditional types: the type-level if

A conditional type picks one of two types based on whether a type is assignable to another — X extends Y ? A : B. Combined with infer, it becomes pattern matching on types.

Conditional types and infer

Purpose of the example: extract the element type of an array and unwrap a promise — the two canonical "type destructuring" patterns.

// If T is an array type, capture ("infer") its element type as U; else keep T.
type ElementType<T> = T extends (infer U)[] ? U : T;

type A = ElementType<string[]>;   // string
type B = ElementType<number[]>;   // number
type C = ElementType<boolean>;    // boolean — the else branch

// The same pattern unwraps one layer of a Promise:
type Awaited1<T> = T extends Promise<infer V> ? V : T;
type D = Awaited1<Promise<string>>;  // string

What you should see: hover A, B, C, D — the editor displays the resolved types. infer U introduces a type variable inside the pattern, like a destructuring declaration but for types; the checker fills it in and uses it in the true branch.

Distributive conditionals and never

One subtlety trips everyone: conditional types distribute over unions. This behavior is load-bearing in idiom — and the reason never shows up in filters.

Purpose of the example: see distribution turn one conditional into several, and use never to subtract from a union.

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

// Distributes: applied to each member separately, then unioned.
type X = ToArray<string | number>;  // string[] | number[]
// — NOT (string | number)[]! Write [T] if you want to block distribution.

// Filtering: the false branch yields `never`, and never vanishes from unions.
type NonNull<T> = T extends null | undefined ? never : T;
type Y = NonNull<string | null | undefined>;  // string

// never is the empty set of types: string | never is just string.
// That is exactly how the standard library defines NonNullable.

What you should see: the difference between string[] | number[] and (string | number)[] is real and checkable — hover both variants. What we learn: never acts as the type-level zero element, which makes union arithmetic (add with |, subtract via conditional filters) work like set algebra.

Utility types as architecture

The standard library's utilities are your first stop — composing them usually beats writing custom type code.

Pick, Omit, Partial, Readonly, Record

Purpose of the example: shape layer-specific contracts from one domain type — the daily workflow of a typed codebase.

interface Article {
  id: string;
  title: string;
  body: string;
  views: number;
  updatedAt: Date;
}

type ArticleDraft = Omit<Article, "id" | "views" | "updatedAt">;  // what POST accepts
type ArticleSummary = Pick<Article, "id" | "title">;              // what lists render
type ArticlePatch = Partial<Omit<Article, "id">>;                 // what PATCH accepts
type ArticlesById = Record<string, Article>;                      // a keyed lookup table

function createArticle(draft: ArticleDraft): Article {
  // `id` and `views` cannot be forgotten here — the type demands them
  return { ...draft, id: crypto.randomUUID(), views: 0, updatedAt: new Date() };
}

What you should see: add a field to Article and every derived type updates; make id optional and createArticle still compiles because it sets the fields itself. What we learn: derive types from the domain type instead of hand-maintaining near-duplicates — the single highest-leverage habit in a typed codebase.

Mapped and conditional types

Utility types are not compiler magic — they are built from two primitives you can use yourself: mapped types (transform every property) and conditional types (choose a type based on a test). Master these and the type system becomes a small functional language.

Mapped types transform every property

Purpose of the example: rebuild two famous utilities by hand to see the machinery inside them.

// A mapped type: for each key K in T, produce a property of the new shape.
// The `?` and `-?` modifiers add/remove optionality; `readonly` works the same way.
type MyPartial<T> = {
  [K in keyof T]?: T[K];        // same keys, each optional, same value types
};

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]; // same keys, none writable
};

// Key remapping (TS 4.1+): template literal types rename keys on the fly.
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Point { x: number; y: number }
// Getters<Point> = { getX: () => number; getY: () => number } — check it on hover

What you should see: hovering Getters<Point> shows renamed methods generated from the keys of Point — the type system producing new properties from old ones. What we learn: [K in keyof T] is a loop over property names, T[K] is an index into the value types, and the as clause is a rename step. That is the entire model.

Conditional types choose based on a test

Purpose of the example: write type-level branching, including the recursive case that powers Awaited and deep utilities.

// A conditional type: C extends B ? Then : Else — evaluated by the checker.
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>;   // string — infer captures the inner type
type B = Unwrap<number>;           // number — test fails, else branch

// Recursion makes it work through nested promises, like the built-in Awaited:
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type C = DeepUnwrap<Promise<Promise<boolean>>>; // boolean

// distributive conditional: applied to a union, it maps over each member —
// which is why NonNullable<string | null> filters the union member by member.
type ToArray<T> = T extends unknown ? T[] : never;
type D = ToArray<string | number>; // string[] | number[], NOT (string | number)[]

What you should see: the infer U pattern — "match a Promise and name its inner type" — is the workhorse of every typed async library. Note the last case: conditionals distribute over unions, which is a feature to exploit deliberately. What we learn: with mapped types for iteration and conditional types for branching, a type can be a program — but each program you write is a claim on the next reader's attention.

Flow: input type T passes through keyof to a mapped type loop, and through extends tests with infer capture in a conditional type; both feed derived types
Figure 1 — Type-level computation. Mapped types iterate ([K in keyof T]); conditional types branch (extends ? :) and capture (infer). Every utility type you have ever used is some composition of these two machines.

Discipline: when advanced types earn their keep

The advanced toolbox is where TypeScript teams win or lose readability. Two rules keep it professional.

The three honest uses of type assertions

as overrides the checker without proof — it is a scoped version of @ts-ignore. Legitimate uses are narrow:

// 1. Narrowing a known-specific value (e.g. a DOM lookup the checker can't type):
const canvas = document.getElementById("stage") as HTMLCanvasElement;

// 2. Bridging an untyped boundary AFTER validation:
const data: unknown = JSON.parse(raw);
const cfg = data as Config;   // acceptable ONLY with a validation step — see below

// 3. Interoperating with a mis-typed dependency, commented and localized.

// The dishonest use: silencing a real error.
const n = "42" as unknown as number;  // double assertion = an alarm bell;
                                      // the string is still a string at runtime

What you should see: the double assertion (as unknown as) is the compiler's own warning sign that you are lying in two steps. What we learn: every assertion is a claim that a runtime invariant exists. If code outside the type system produced the value, validate it first (Chapter: errors & validation) — then the assertion is provable rather than hopeful.

A decision checklist for custom type code

Before writing any mapped or conditional type, answer four questions: (1) Does a built-in utility already do this — Parameters, ReturnType, Awaited, NonNullable? (2) Will the hover on the result be readable to a colleague who has never seen it? (3) Does the type appear in more than one place, or would it be dead abstraction? (4) Can the test suite exercise the runtime behavior it describes? Advanced types in production codebases follow the same rule as clever algorithms: pay for them once in a well-documented utility, not everywhere in business logic.

Practice: build your own utility library

Derive the form contract

Given interface Signup { email: string; password: string; promoCode?: string }, write type SignupForm where every field is optional and every string field may be empty — using mapped types over keyof, not a hand-written duplicate interface. What you should see: renaming promoCode in Signup updates SignupForm with zero edits. What we learn: the mapped type is the contract; the interface is the single source of truth.

Write an event map with guarded keys

Define type EventEmitter<T extends Record<string, unknown[]>> so that on("score", (points: number) => void) checks but on("scroe", …) is an error — key remapping plus an indexed parameter list. What you should see: a typo in the event name becomes a compile error listing the valid alternatives. What we learn: this exact pattern is how libraries like socket.io and mitt made stringly-typed APIs checkable.

With the computation tools covered, the declarations lesson turns to the boundary where your types meet code you did not write.