A mapped type walks over a set of keys and produces a property for each one. Before TypeScript 4.1 the output keys had to be the input keys, so you could change value types (Partial, Readonly) but not names. The as clause lifted that restriction. You can now rename keys, filter them out, and even build an object type from a union that has nothing to do with keys. That’s what lets libraries offer typed onCartAdded handlers or getTheme() accessors derived from a single source type.
The syntax is short: { [K in Keys as NewKey]: Value }. Most of the interesting behaviour is in what NewKey is allowed to be and what happens at the edges.
Renaming keys, and why string & K appears everywhere
The standard first example generates getter names. Here’s the version most people write first, next to the one that compiles:
type BadGetters<T> = {
[K in keyof T as `get${Capitalize<K>}`]: () => T[K];
};
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};TypeScript 7.0 rejects the first:
getters.ts(2,37): error TS2344: Type 'K' does not satisfy the constraint 'string'.
Type 'keyof T' is not assignable to type 'string'.
Type 'string | number | symbol' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.The error chain is worth reading. keyof T for an arbitrary T is string | number | symbol, because objects can have numeric and symbol keys. Capitalize only accepts strings. Intersecting with string keeps the string keys and turns the others into never, and (this is the useful part) a key remapped to never simply disappears from the result.
You can see that with a type that has all three kinds of key:
interface Settings {
readonly theme: 'light' | 'dark';
fontSize?: number;
0: string;
[Symbol.iterator]: () => Iterator<string>;
}
type S = Getters<Settings>;S is { getTheme: () => 'light' | 'dark'; getFontSize?: () => number | undefined }. The numeric key 0 and the symbol key were silently dropped. That’s usually what you want for getters, but in an interview it’s worth saying out loud: string & K is a filter, not just a way to satisfy the compiler. If you needed numeric keys too, you’d write `get${K & (string | number)}`, since template literal types accept numbers but not symbols.
Modifiers survive, collisions merge
Notice getFontSize? above. When a mapped type iterates over keyof T (directly, with or without as), TypeScript treats it as homomorphic and copies each property’s readonly and ? modifiers to the output. That holds for renamed keys:
type Prefixed<T> = { [K in keyof T as `x_${string & K}`]: T[K] };
type P = Prefixed<Settings>;
const p: P = { x_theme: 'dark' };
p.x_theme = 'light';settings.ts(14,3): error TS2540: Cannot assign to 'x_theme' because it is a read-only property.The object literal without x_fontSize was accepted (optional preserved) and the assignment was rejected (readonly preserved). If you need to strip modifiers, add -readonly or -? to the mapped type as usual.
What if two input keys map to the same output key? Converting snake_case to camelCase can do that when a type has both user_id and userId. TypeScript doesn’t error; it merges the properties and the value type becomes a union of both. In a small test, mapping 'user_id' | 'userId' to camelCase and using the original key as the value type gave a single userId property of type 'userId' | 'user_id'. That’s legal but rarely intended, so a key-transforming type over API data deserves a test case for collisions.
Filtering keys with never
Because a never key is removed, the as clause doubles as a filter. A common use is stripping methods to get the plain data shape of a class, for example the fields you’d persist:
class Invoice {
id = 'inv_1';
totalCents = 4598;
paidAt?: Date;
markPaid(at = new Date()) { this.paidAt = at; }
toJSON() { return { id: this.id, totalCents: this.totalCents }; }
}
type DataOnly<T> = {
[K in keyof T as T[K] extends (...args: any[]) => unknown ? never : K]: T[K];
};
type InvoiceData = DataOnly<Invoice>;
const row: InvoiceData = { id: 'inv_2', totalCents: 100 };
const bad: InvoiceData = { id: 'inv_3', totalCents: 100, markPaid() {} };invoice.ts(15,58): error TS2353: Object literal may only specify known properties, and 'markPaid' does not exist in type 'DataOnly<Invoice>'.Before the as clause, this needed two steps: compute the union of non-function keys with a separate conditional type, then Pick them. Doing it inline is shorter and keeps modifiers, since it’s still homomorphic (paidAt stays optional, which is why row compiles without it).
There’s a trap here that shows up in React code. An optional callback prop such as onClick?: () => void has the value type (() => void) | undefined, and that union does not extend a function type, so DataOnly keeps it. Testing NonNullable<T[K]> instead fixes it:
type DataOnly<T> = {
[K in keyof T as T[K] extends (...args: any[]) => unknown ? never : K]: T[K];
};
type DataOnly2<T> = {
[K in keyof T as NonNullable<T[K]> extends (...args: any[]) => unknown ? never : K]: T[K];
};
interface Props { label: string; onClick?: () => void }
const k1: keyof DataOnly<Props> = 'onClick';
const k2: keyof DataOnly2<Props> = 'onClick';props.ts(9,7): error TS2322: Type '"onClick"' is not assignable to type '"label"'.k1 compiles, proving the first version kept onClick. k2 fails, proving the second removed it.
Mapping over a union of events
The in clause doesn’t have to iterate over keys. It can iterate over any union, and the as clause then computes a key from each member. That makes a discriminated union of events into a handler map in one step:
type AppEvent =
| { type: 'cart.added'; sku: string; qty: number }
| { type: 'cart.removed'; sku: string }
| { type: 'checkout.started'; totalCents: number };
type CamelCase<S extends string> = S extends `${infer Head}.${infer Tail}`
? `${Head}${Capitalize<CamelCase<Tail>>}`
: S;
type Handlers = {
[E in AppEvent as `on${Capitalize<CamelCase<E['type']>>}`]: (event: E) => void;
};
function dispatch(event: AppEvent, handlers: Partial<Handlers>): void {
const [head, ...rest] = event.type.split('.');
const name = 'on' + [head, ...rest].map((w) => w[0].toUpperCase() + w.slice(1)).join('');
const handler = handlers[name as keyof Handlers] as ((e: AppEvent) => void) | undefined;
if (handler) handler(event);
else console.log(`no handler for ${event.type}`);
}
const handlers: Partial<Handlers> = {
onCartAdded: (e) => console.log(`added ${e.qty} x ${e.sku}`),
onCheckoutStarted: (e) => console.log(`checkout for ${(e.totalCents / 100).toFixed(2)}`),
};
dispatch({ type: 'cart.added', sku: 'TEE-M', qty: 2 }, handlers);
dispatch({ type: 'cart.removed', sku: 'TEE-M' }, handlers);
dispatch({ type: 'checkout.started', totalCents: 4598 }, handlers);It compiles under --strict, and Node prints:
added 2 x TEE-M
no handler for cart.removed
checkout for 45.98E is a whole event object, not a key, so the value type (event: E) => void gets the precise member. Inside onCartAdded, e.qty is known to exist. Try a wrong field or a misspelled handler and you get:
handlers.ts(12,37): error TS2339: Property 'totalCents' does not exist on type '{ type: "cart.added"; sku: string; qty: number; }'.
handlers.ts(13,3): error TS2561: Object literal may only specify known properties, but 'onCartRemove' does not exist in type 'Partial<Handlers>'. Did you mean to write 'onCartRemoved'?Now look at dispatch. It has two casts, and that’s not sloppiness. Mapped types exist only at compile time, so the runtime still has to build the handler name with string code, and TypeScript can’t prove that the string 'on' + ... equals the computed key type, or that the handler found matches this particular event. The type-level CamelCase and the runtime split/map must agree, and only a test checks that. This is the honest boundary of the technique: callers get excellent types, and the one function that bridges types to runtime needs care and coverage.
Interview exercise: API rows to a domain object
Your database client returns rows typed as { user_id: number; created_at: string; is_admin: boolean; _version: number }. Write a type DomainUser that camel-cases every key and drops keys starting with an underscore, and say what you’d test.
Answer
type SnakeToCamel<S extends string> = S extends `${infer H}_${infer T}`
? `${H}${Capitalize<SnakeToCamel<T>>}`
: S;
type ToDomain<T> = {
[K in keyof T as K extends `_${string}` ? never : SnakeToCamel<string & K>]: T[K];
};
type Row = { user_id: number; created_at: string; is_admin: boolean; _version: number };
type DomainUser = ToDomain<Row>;
const u: DomainUser = { userId: 1, createdAt: '2026-10-19T09:00:00Z', isAdmin: false };That compiles with no errors, and adding _version: 3 to u is rejected as an unknown property. The as clause does both jobs: underscore keys map to never and vanish, and the rest go through a recursive template literal type. string & K keeps the non-string keys out of SnakeToCamel. Because the mapping is over keyof T, any optional or readonly columns keep those modifiers.
What I’d test: first, that the runtime converter (the function that actually renames keys on each row) produces exactly the keys the type promises, using one sample row per table, since nothing links the two automatically. Second, collisions: if a table ever has both user_id and userId, the type would quietly merge them into one property with a union type, so I’d add a compile-time assertion or a lint rule that rejects that schema. Third, edge cases in the string logic, such as a double underscore (a__b), where the type and a naive runtime regex can easily disagree.