Chapter 9

Mapped Types: Transforming Object Shapes, DeepPartial

The Core Syntax

A mapped type generates a new type by iterating over the keys of an existing type โ€” the type-level equivalent of Array.map.

// Basic syntax: iterate every key of T, produce a new type
type Copy<T> = {
  [K in keyof T]: T[K];
};

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

type UserCopy = Copy<User>;
// { id: number; name: string; email: string }

K in keyof T is the engine: keyof T produces a union of all keys, and in iterates over them.

Modifiers: Adding and Removing readonly / ?

Mapped types can add or remove readonly and optional ? from every property.

// Add readonly (the + prefix is optional)
type Immutable<T> = {
  +readonly [K in keyof T]: T[K];
};

// Remove readonly (unfreeze a type)
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

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

// Remove optional (all fields become required)
type Required<T> = {
  [K in keyof T]-?: T[K];
};

In practice:

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

type MutableConfig = Mutable<Config>;
// { host: string; port?: number; timeout: number }
// host is no longer readonly

type AllRequired = Required<Config>;
// { host: string; port: number; timeout: number }
// port is no longer optional

Remapping Keys with as

TypeScript 4.1 added the as clause โ€” you can transform key names during iteration.

// Convert every key to a getter method name
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;
// }

string & K is necessary: K has type string | number | symbol, but Capitalize only accepts string. The intersection filters out non-string keys.

Filtering Keys with as ... never

When the as clause evaluates to never, that key is dropped from the result type.

// Keep only keys whose value extends a certain type
type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

interface Mixed {
  id: number;
  name: string;
  active: boolean;
  score: number;
}

type StringKeys = PickByValue<Mixed, string>;
// { name: string }

type NumberKeys = PickByValue<Mixed, number>;
// { id: number; score: number }

Practical Mapped Types

Nullable<T>: Make All Fields Accept null

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }

EventMap<T>: Generate Handler Names from an Interface

interface AppEvents {
  userLogin: { userId: string };
  dataLoaded: { count: number };
  errorOccurred: { message: string };
}

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

type AppHandlers = EventHandlers<AppEvents>;
// {
//   onUserLogin:     (payload: { userId: string }) => void;
//   onDataLoaded:    (payload: { count: number })  => void;
//   onErrorOccurred: (payload: { message: string }) => void;
// }

Main Project: Implementing DeepPartial<T>

The standard library's Partial<T> is shallow โ€” nested objects remain fully required.

interface FormData {
  user: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
  settings: {
    theme: "light" | "dark";
    notifications: boolean;
  };
}

// Standard Partial โ€” only the top level becomes optional
type ShallowPartial = Partial<FormData>;
// {
//   user?: {
//     name: string;        โ† still required inside!
//     address: { city: string; zip: string; }
//   };
//   settings?: { theme: ...; notifications: boolean; }
// }

Step 1: Identify the Recursive Case

// Wrong approach: no base case โ€” primitive types get recursed, breaking them
type BadDeepPartial<T> = {
  [K in keyof T]?: BadDeepPartial<T[K]>; // string becomes BadDeepPartial<string> โ€” broken
};

Step 2: Correct Implementation

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

T extends object is the guard: only object types recurse. string, number, boolean, etc., are returned as-is.

Step 3: Verify It Works

type PartialFormData = DeepPartial<FormData>;

// Valid: update only a deep field
const patch: PartialFormData = {
  user: {
    address: {
      city: "Shanghai", // zip can be omitted
    },
    // name can also be omitted
  },
};

// Typical usage in API PATCH requests
async function updateForm(id: string, patch: DeepPartial<FormData>) {
  const response = await fetch(`/api/forms/${id}`, {
    method: "PATCH",
    body: JSON.stringify(patch),
  });
  return response.json();
}

Handling Arrays

// Elements inside arrays also need recursion
type DeepPartialWithArray<T> = T extends Array<infer Item>
  ? Array<DeepPartialWithArray<Item>>
  : T extends object
  ? { [K in keyof T]?: DeepPartialWithArray<T[K]> }
  : T;

interface Catalog {
  products: Array<{
    id: number;
    name: string;
    specs: { weight: number; color: string };
  }>;
}

type PatchCatalog = DeepPartialWithArray<Catalog>;
// products: Array<{ id?: number; name?: string; specs?: { weight?: number; color?: string } }>

Main Project: Implementing DeepReadonly<T>

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

interface AppState {
  user: {
    id: string;
    preferences: {
      language: string;
      theme: string;
    };
  };
  cache: Map<string, unknown>;
}

type FrozenState = DeepReadonly<AppState>;

declare const state: FrozenState;
// state.user.preferences.language = "zh"; // Error: read-only
// state.user = { ... };                    // Error: read-only

Anti-Pattern: Over-Engineering with Mapped Types

// โŒ Anti-pattern: using a mapped type where Pick already exists
type ComplexPick<T, K extends keyof T> = {
  [P in K]: T[P]; // identical to Pick<T, K> โ€” no benefit
};

// โœ“ Just use Pick
type UserPreview = Pick<User, "id" | "name">;

// โŒ Anti-pattern: three or more levels of nesting destroys readability
type OverEngineered<T> = {
  [K in keyof T as K extends string
    ? `__${Uppercase<K>}__`
    : never]: T[K] extends object
    ? { [P in keyof T[K]]: T[K][P] extends string ? `[${T[K][P]}]` : T[K][P] }
    : T[K];
};
// Split into named intermediate types instead

Comparison Table

Operation Syntax Effect
Add optional [K in keyof T]?: All fields become optional
Remove optional [K in keyof T]-?: All fields become required
Add readonly readonly [K in keyof T]: All fields become readonly
Remove readonly -readonly [K in keyof T]: Readonly removed
Rename keys [K in keyof T as NewKey]: Key name transformation
Filter keys [K in keyof T as ... ? K : never]: Conditional key removal
Recursive transform T extends object ? {...} : T Deep traversal

Chapter Summary

Concept Key Point
[K in keyof T] Iterates all keys โ€” the foundation of mapped types
+? / -? Add or remove the optional modifier
-readonly Remove readonly (how the standard Mutable pattern works)
as NewKey Rename keys during mapping; returning never drops the key
DeepPartial<T> Recursive mapping + extends object guard for primitives
DeepReadonly<T> Arrays use ReadonlyArray; objects recurse with readonly

What's Next

Chapter 10 covers template literal types โ€” moving string operations into the type system. You'll see how to extract parameters automatically from a route string like "/users/:id/posts/:postId" and build a type-safe i18n key system.

Rate this chapter
4.7  / 5  (38 ratings)

๐Ÿ’ฌ Comments