Transforming Types, Not Values
A checkout form had a model plus three parallel types: field errors, touched flags and change handlers. A deliveryNotes field was added to the model and nowhere else. Its validation errors were silently dropped, and the change handler only compiled with a cast.
Mapped types let you write “one of these per field” once and derive it from the model. With key remapping and template literals they can rename keys too, and with recursion they can transform nested types (carefully). Below is the foundation, key remapping, combining with template literals, and deep transformations that do not break dates and arrays.
- 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<K>}`]"]
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
});Mapped Types Over Tuples and Constant Objects
A mapped type applied to a tuple produces a tuple, not an object. Each position is transformed and the length is kept, which is what makes typed wrappers around functions such as Promise.all possible.
type Promisified<T extends readonly unknown[]> = { [K in keyof T]: Promise<T[K]> };
type Inputs = Promisified<[string, number, boolean]>;
// [Promise<string>, Promise<number>, Promise<boolean>]The second pattern goes the other way: start from a constant object and derive the types from it, so that the values and the types cannot fall out of step.
const STATUS = { open: 'Open', shipped: 'Shipped', cancelled: 'Cancelled' } as const;
type StatusKey = keyof typeof STATUS; // 'open' | 'shipped' | 'cancelled'
type StatusLabel = (typeof STATUS)[StatusKey]; // 'Open' | 'Shipped' | 'Cancelled'
type StatusHandlers = { [K in StatusKey]: (id: string) => void };Adding a status to the object now updates every derived type, and any handler map that lacks the new key stops compiling. Prefer this to a hand-maintained union kept beside the data.
The Checkout Form, Derived
Here is the form from the introduction rebuilt so that adding deliveryNotes to the model is the only change anyone has to make. Errors, touched flags and change handlers all come from the same keys:
interface CheckoutModel {
email: string;
postcode: string;
deliveryNotes?: string;
saveCard: boolean;
}
type FieldErrors<T> = { [K in keyof T]-?: string | undefined };
type Touched<T> = { [K in keyof T]-?: boolean };
type Handlers<T> = { [K in keyof T as `on${Capitalize<string & K>}Change`]-?: (value: T[K]) => void };
type CheckoutForm = {
values: CheckoutModel;
errors: FieldErrors<CheckoutModel>;
touched: Touched<CheckoutModel>;
} & Handlers<CheckoutModel>;
// form.onDeliveryNotesChange exists, typed (value: string | undefined) => void
// form.errors.deliveryNotes must be provided, so validation cannot skip itTwo details are doing real work. A mapped type over keyof T is homomorphic: it copies the optional and readonly modifiers from the model, which is why deliveryNotes? would otherwise make its error entry optional too. The -? modifier removes that, so every field must have an error slot and a touched flag, even optional ones. The handler type still usesT[K], so the optional field’s handler correctly accepts undefined.
Type-Level Code Has a Cost
Mapped and conditional types are a small programming language, and it is easy to write slow programs in it. Two symptoms tell you that you have gone too far: the editor takes seconds to show errors after a keystroke, or the compiler stops with “Type instantiation is excessively deep and possibly infinite”. Both usually trace back to recursive types applied to large or unbounded structures, or to template literal unions multiplying into tens of thousands of members.
Measure before guessing. tsc --extendedDiagnostics prints check time and instantiation counts, andtsc --generateTrace ./trace writes a trace you can open in a browser profiler to find the type that dominates. The usual fixes are to cap recursion depth with a counter parameter, to stop recursing into types such as Date, Map and arrays, and to give exported functions explicit return types, so the compiler does not have to re-derive a huge type at every call site.
Finally, test the types. A mapped type is code with no runtime behaviour, so normal tests never exercise it. Keep a small file of type assertions, either with expectTypeOf from Vitest or with // @ts-expect-error lines that must keep failing:
import { expectTypeOf } from 'vitest';
expectTypeOf<Touched<CheckoutModel>>().toEqualTypeOf<{
email: boolean; postcode: boolean; deliveryNotes: boolean; saveCard: boolean;
}>();
declare const handlers: Handlers<CheckoutModel>;
// @ts-expect-error saveCard is a boolean, so a string must be rejected
handlers.onSaveCardChange('yes');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.

