TypeScript Mapped Types — Transform and Derive Types at Scale

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Mapped types are TypeScript's most powerful tool for eliminating type duplication. Instead of copying an interface and manually making every field optional or readonly, you write a single mapped type and derive it automatically. The result is a single source of truth — change the base interface and all derived types update automatically.

For backend engineers, mapped types power essential patterns: creating DTO types from entity interfaces, building type-safe event listener registries, generating getter/setter pairs, and constructing validation schemas that mirror domain models. Built-in utility types like Partial, Readonly, and Record are all implemented as mapped types internally.

Understanding mapped types from first principles also lets you write your own utilities for domain-specific needs — deep partial configs, nullable database rows, or filterable API response shapes.

Basic Mapped Type Syntax

A mapped type iterates over the keys of an existing type using [K in keyof T].

interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}
 
// Replicate the built-in Partial<T>
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};
 
// Replicate the built-in Readonly<T>
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
// Make every property nullable
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};
 
type NullableUser = Nullable<User>;
// { id: string | null; name: string | null; email: string | null; age: number | null }

Modifier Addition and Removal

Mapped types can add or remove readonly and ? modifiers using + and - prefixes.

// Remove readonly from all properties
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};
 
// Remove optional from all properties
type Complete<T> = {
  [K in keyof T]-?: T[K];
};
 
interface PartialConfig {
  host?: string;
  port?: number;
  debug?: boolean;
}
 
type RequiredConfig = Complete<PartialConfig>;
// { host: string; port: number; debug: boolean }
 
const config: RequiredConfig = {
  host: 'localhost',
  port: 3000,
  debug: false,
};

Key Remapping with as

TypeScript 4.1+ allows remapping keys using the as clause. This enables renaming, filtering, and template literal transformations.

// Generate getter method names from property names
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
type UserGetters = Getters<User>;
// { getId: () => string; getName: () => string; getEmail: () => string; getAge: () => number }
 
// Generate setter method names
type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
 
// Filter properties — keys that map to never are removed
type StringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type UserStringFields = StringProperties<User>;
// { name: string; email: string }
// (id excluded if it were number, age excluded as number)
 
// Filter out methods — keep only data properties
type DataProperties<T> = {
  [K in keyof T as T[K] extends Function ? never : K]: T[K];
};

Conditional Mapped Types

Combine mapped types with conditional types for powerful transformations.

// Make properties optional only if they match a certain type
type OptionalStrings<T> = {
  [K in keyof T]: T[K] extends string ? T[K] | undefined : T[K];
};
 
// Create a validation error shape from any interface
type ValidationErrors<T> = {
  [K in keyof T]?: string;
};
 
// Create a form state type from a data model
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: Partial<Record<keyof T, boolean>>;
  dirty: Partial<Record<keyof T, boolean>>;
};
 
interface LoginForm {
  email: string;
  password: string;
  rememberMe: boolean;
}
 
type LoginFormState = FormState<LoginForm>;

Recursive Mapped Types

// Deep partial — recursively make nested objects optional
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;
 
// Deep readonly — freeze entire object tree
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;
 
interface AppConfig {
  server: {
    host: string;
    port: number;
    tls: {
      enabled: boolean;
      certPath: string;
    };
  };
  database: {
    url: string;
    pool: { min: number; max: number };
  };
}
 
// Override only deeply nested fields
const patch: DeepPartial<AppConfig> = {
  database: {
    pool: { max: 20 },
  },
};
 
// Immutable config object
const frozenConfig: DeepReadonly<AppConfig> = {
  server: { host: 'localhost', port: 3000, tls: { enabled: false, certPath: '' } },
  database: { url: 'postgres://localhost/db', pool: { min: 2, max: 10 } },
};
// frozenConfig.database.pool.max = 5; // Error: readonly

Real-World Patterns

Type-Safe Event Registry

interface EventMap {
  'user.created': { userId: string; email: string };
  'user.deleted': { userId: string };
  'order.placed': { orderId: string; total: number };
  'payment.failed': { orderId: string; reason: string };
}
 
type EventListeners = {
  [K in keyof EventMap]: (payload: EventMap[K]) => void | Promise<void>;
};
 
type EventRegistry = Partial<EventListeners>;
 
class TypedEventBus {
  private listeners: Partial<EventListeners> = {};
 
  on<K extends keyof EventMap>(event: K, handler: EventListeners[K]): void {
    this.listeners[event] = handler as EventListeners[K];
  }
 
  async emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): Promise<void> {
    await this.listeners[event]?.(payload as Parameters<EventListeners[K]>[0]);
  }
}

API Mock Generator

type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
  T extends (...args: any[]) => Promise<infer R> ? R : never;
 
type MockService<T extends Record<string, (...args: any[]) => Promise<any>>> = {
  [K in keyof T]: jest.MockedFunction<T[K]>;
};

Common Mistakes

  • Using keyof T without constraining to string when template literal types require it — use string & keyof T
  • Creating deeply nested recursive mapped types that slow TypeScript's compiler significantly
  • Forgetting that keys remapped to never are silently dropped — this can produce empty types unexpectedly
  • Mixing mapped types with regular interface properties — keep them separate for clarity
  • Over-engineering type transformations when a simple Pick or Omit would suffice

Best Practices

  • Start with built-in utility types (Partial, Readonly, Record) before writing custom mapped types
  • Name custom mapped types after their transformation: Nullable<T>, Mutable<T>, Stringified<T>
  • Use as key remapping instead of manually recreating types with renamed properties
  • Test complex mapped types by assigning example values and checking hover types in your IDE
  • Keep recursive mapped types bounded — add a depth limit or use them only for 2-3 nesting levels

Key Takeaways

  • Mapped types iterate over keyof T to systematically transform every property of a type
  • +? adds optional modifier, -? removes it; +readonly adds readonly, -readonly removes it
  • The as clause in mapped types enables key renaming, filtering with never, and template literal generation
  • [K in keyof T as T[K] extends SomeType ? K : never] filters properties by their value type
  • DeepPartial<T> and DeepReadonly<T> are recursive mapped types not available in the standard library
  • Built-in types Partial, Required, Readonly, and Record are all implemented as mapped types internally
  • Template literal types combined with Capitalize generate onEvent, getField, and setField key patterns
  • Mapped types are compile-time only — they produce zero JavaScript output and have no runtime cost

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading