Transforming Types, Not Values

Mapped types iterate over keys and produce a new object type. Template literal types concatenate string types at the type level. Together, they let you derive entire type families from a single source โ€” an API response type becomes a form state type, a config interface becomes a validated config type, a string union becomes a set of event handler names. No duplication, no drift.

๐Ÿค” Sound familiar?
  • You have a type and a "partial version" of it defined separately that you keep manually syncing
  • You write out { onClick?: () => void; onFocus?: () => void; onBlur?: () => void } by hand instead of deriving it
  • You've seen { [K in keyof T]: ... } in TypeScript source and moved on without understanding it
  • You want event handler types from a list of event names but don't know how to generate them

Mapped types with template literals eliminate whole categories of manual type maintenance โ€” one source type, infinite derived shapes.

Mapped Types: The Foundation

// Basic form: iterate over keys, transform each value
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Partial<T> = {
  [K in keyof T]?: T[K];
};

type Required<T> = {
  [K in keyof T]-?: T[K]; // -? removes optionality
};

// Remove readonly modifier
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Custom transformation: wrap every value in a Promise
type Async<T> = {
  [K in keyof T]: Promise<T[K]>;
};

interface Config { timeout: number; endpoint: string; }
type AsyncConfig = Async<Config>;
// { timeout: Promise<number>; endpoint: Promise<string>; }

Key Remapping with as

TypeScript 4.1+ allows remapping keys with the as clause in mapped types. This is how you generate new property names from existing ones.


flowchart LR
    src["Source Type
{ name: string; age: number }"]
    mapped["Mapped Type
[K in keyof T as `get${Capitalize&lt;K&gt;}`]"]
    out["Output Type
{ getName: () => string;
  getAge: () => number }"]
    src --> mapped --> out

    style mapped fill:#0078D4,color:#fff,stroke:#005a9e
// Remap keys using template literals
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

type UserGetters = Getters<User>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

// Filter keys using 'never' in the remapping
type StringKeys<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Mixed { id: string; count: number; label: string; active: boolean; }
type StringOnly = StringKeys<Mixed>;
// { id: string; label: string; }

Template Literal Types

Template literal types concatenate string literal types โ€” they work exactly like template literals in JavaScript, but at the type level.

type Direction = 'top' | 'right' | 'bottom' | 'left';
type CSSProperty = `margin-${Direction}`;
// 'margin-top' | 'margin-right' | 'margin-bottom' | 'margin-left'

// Combine multiple unions โ€” creates a cartesian product
type Color = 'red' | 'green' | 'blue';
type Shade = 'light' | 'dark';
type ColorVariant = `${Shade}-${Color}`;
// 'light-red' | 'light-green' | 'light-blue' | 'dark-red' | 'dark-green' | 'dark-blue'

// TypeScript intrinsic string manipulation types:
type U = Uppercase<'hello'>;     // 'HELLO'
type L = Lowercase<'WORLD'>;     // 'world'
type C = Capitalize<'hello'>;    // 'Hello'
type UC = Uncapitalize<'Hello'>; // 'hello'

Combining Mapped Types with Template Literals

// Generate event handler types from an event name union
type EventName = 'click' | 'focus' | 'blur' | 'change';

type EventHandlers = {
  [E in EventName as `on${Capitalize<E>}`]?: (event: Event) => void;
};
// {
//   onClick?: (event: Event) => void;
//   onFocus?: (event: Event) => void;
//   onBlur?: (event: Event) => void;
//   onChange?: (event: Event) => void;
// }

// Generate a CSS-in-JS style object type
type SpacingKey = 'margin' | 'padding';
type Side = 'Top' | 'Right' | 'Bottom' | 'Left';

type SpacingProperties = {
  [K in `${SpacingKey}${Side}`]?: string | number;
};
// { marginTop?: ...; marginRight?: ...; paddingLeft?: ...; ... }

Deep Type Transformations

// Convert an interface to a "form state" equivalent
// Every value becomes string | null (as it would be in a form)
type FormState<T> = {
  [K in keyof T]: T[K] extends object
    ? FormState<T[K]>
    : string | null;
};

// Convert to "nullable" version
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

// Convert object to a "field descriptor" map
type FieldDescriptor<T> = {
  [K in keyof T]: {
    label: string;
    defaultValue: T[K];
    required: boolean;
  };
};

Real-World: Typed Pub/Sub

// Define your event map once
interface AppEvents {
  'user:created': { id: string; email: string };
  'user:deleted': { id: string };
  'order:placed': { orderId: string; total: number; userId: string };
  'order:cancelled': { orderId: string; reason: string };
}

// Generate strongly-typed emitter interface from the map
type EventEmitter<Events extends Record<string, unknown>> = {
  on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void;
  emit<K extends keyof Events>(event: K, data: Events[K]): void;
  off<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void;
};

declare const emitter: EventEmitter<AppEvents>;

// Fully typed โ€” event name and payload are checked together
emitter.on('user:created', (data) => {
  console.log(data.email); // โœ… string
  console.log(data.total); // โŒ Error: 'total' doesn't exist on user:created payload
});

emitter.emit('order:placed', {
  orderId: '123',
  total: 99.99,
  userId: 'u1',
  // โŒ Missing 'reason'? No error โ€” reason is on order:cancelled, not here
});

Pitfalls

Template literal cartesian products blow up

Combining two unions of size N each creates Nยฒ type members. Combining three creates Nยณ. TypeScript has limits on union size โ€” keep template literal unions small (under ~20 members each) or TypeScript will error or degrade to string.

Mapped type modifiers are not inherited

When you write [K in keyof T]: T[K], you copy values but not modifiers. Optional (?) and readonly ARE preserved by default in homomorphic mapped types. If you want to explicitly control them, use +?, -?, +readonly, or -readonly.

Key remapping to never removes the key

This is intentional and useful for filtering, but it can be surprising. If your mapped type produces fewer keys than the source, check if your as clause produces never for some keys.