Ch. 2 · TypeScript

TypeScript Key Remapping in Mapped Types

Rename keys with as clauses, drop keys by mapping to never, map over a union of events, and spot the string & K and optional-method traps.

~6 min readadvancedupdated Oct 19, 2026

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

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'.
Text

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>;
TypeScript

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';
TypeScript
settings.ts(14,3): error TS2540: Cannot assign to 'x_theme' because it is a read-only property.
Text

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() {} };
TypeScript
invoice.ts(15,58): error TS2353: Object literal may only specify known properties, and 'markPaid' does not exist in type 'DataOnly<Invoice>'.
Text

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';
TypeScript
props.ts(9,7): error TS2322: Type '"onClick"' is not assignable to type '"label"'.
Text

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);
TypeScript

It compiles under --strict, and Node prints:

added 2 x TEE-M
no handler for cart.removed
checkout for 45.98
Text

E 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'?
Text

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 };
TypeScript

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.

Where to go next

More in TypeScript

read ✓TypeScript · mid

TypeScript Template Literal Types

Build string types from unions, parse route parameters with infer, see what ${number} really accepts, and know where these types stop helping.

~6 min readread →
read ✓TypeScript · mid

TypeScript Infer for Type Extraction

How infer pulls element, return and parameter types out of other types, why readonly arrays don't match T[], and how to parse route strings.

~5 min readread →
read ✓TypeScript · mid

TypeScript Mapped Types and Modifiers

How { [K in keyof T]: T[K] } rebuilds a type, how ?, readonly, -? and -readonly change it, and why Partial stops one level down.

~5 min readread →
esc