Skip to content
C Codeloom
TypeScript

TypeScript Mapped Types: Readonly, Partial from Scratch

Build Partial, Readonly, Pick, and Record yourself with mapped types, modifier control, and key remapping — the toolkit behind every utility type.

·8 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • The { [K in keyof T]: ... } shape
  • The + and - modifiers for readonly and optional
  • Key remapping with as
  • How to build Partial, Readonly, Pick, Record from scratch
  • A preview of template literal types

Prerequisites

If conditional types are control flow at the type level, mapped types are loops. They walk the keys of one type and produce a new type, one property at a time. Almost every utility type in lib.es5.d.ts is a one-liner mapped type — and once you can write them, you can also debug them, which is where most of the payoff lives.

The basic shape

A mapped type iterates over a union of keys and produces an object type:

type Stringify<T> = {
  [K in keyof T]: string;
};

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

type UserAsStrings = Stringify<User>;
// { id: string; name: string; age: string }

Read it as: “for each K in the union keyof T, give the new type a property K whose value type is string.” It is a for loop, but the body produces a property instead of running a side effect.

You can use T[K] to look up the original property’s type:

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

type Same = Identity<User>;   // User, structurally

That is the no-op mapped type. Useful as a starting point — every utility we write below is a modification of it.

Building Partial

Partial<T> makes every property optional. The ? modifier handles it:

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

type PartialUser = MyPartial<User>;
// { id?: number; name?: string; age?: number }

You can apply it for real-world code like a settings updater:

function updateUser(id: string, patch: MyPartial<User>): void {
  // any subset of User keys is fine here
}

updateUser('u1', { name: 'Alice' });             // ok
updateUser('u1', { age: 30, name: 'Bob' });      // ok

Building Readonly

Readonly<T> marks every property readonly:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

const u: MyReadonly<User> = { id: 1, name: 'A', age: 30 };
u.name = 'B';   // Error: Cannot assign to 'name' because it is a read-only property

Adding and removing modifiers

TypeScript lets you add modifiers with + (the default) or remove them with -. The remove form is what powers Required and Mutable:

// Strip optionality from every property
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

// Strip readonly from every property
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

interface Config {
  readonly host: string;
  port?: number;
}

type StrictConfig = MyRequired<Mutable<Config>>;
// { host: string; port: number }

+? and +readonly exist for symmetry but are rarely written — they are the default.

Try it yourself. Write DeepReadonly<T> that applies readonly recursively to every nested object property. Use a conditional to detect objects: T[K] extends object ? DeepReadonly<T[K]> : T[K]. Watch out for arrays and functions, which are also object.

Building Pick

Pick<T, K> keeps only the listed keys. The trick: constrain K to be a subset of keyof T, then iterate over K instead of keyof T:

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type UserNameAndId = MyPick<User, 'id' | 'name'>;
// { id: number; name: string }

P runs over the union you passed in. Because K extends keyof T, the lookup T[P] is always valid.

Building Record

Record<K, V> builds an object type with the given keys and a single value type. The keys can be any subtype of string | number | symbol:

type MyRecord<K extends keyof any, V> = {
  [P in K]: V;
};

type Roles = MyRecord<'admin' | 'editor' | 'viewer', boolean>;
// { admin: boolean; editor: boolean; viewer: boolean }

type CountById = MyRecord<string, number>;
// { [x: string]: number }

When K is a string literal union, you get an exact object type. When K is string, you get an index signature. Same machinery, different shapes.

Key remapping with as

Since TypeScript 4.1, you can transform the key during the mapping with as:

// Prefix every key with 'get' and capitalise it
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User { id: number; name: string; }

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

The ${...} syntax is a template literal type — a string at the type level, with the same interpolation rules as runtime template literals. Capitalize<S> is a built-in intrinsic.

You can also drop keys by mapping them to never. Anything keyed by never disappears:

// Remove all keys of type V
type Without<T, V> = {
  [K in keyof T as T[K] extends V ? never : K]: T[K];
};

interface Mixed { id: number; name: string; flag: boolean; size: number; }

type NoBools = Without<Mixed, boolean>;
// { id: number; name: string; size: number }

That is Omit-by-value-type in three lines.

Building Omit

The built-in Omit<T, K> is itself a mapped type using key remapping:

type MyOmit<T, K extends keyof any> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

type UserWithoutAge = MyOmit<User, 'age'>;
// { id: number; name: string }

The internal definition in lib.es5.d.ts uses a slightly different formulation with Pick and Exclude, but the behaviour is identical.

Try it yourself. Write RenameKey<T, From extends keyof T, To extends string> that renames a single property. The mapping says: “if the key is From, rewrite it to To; otherwise keep it.” Test on User by renaming 'id' to 'userId'.

Template literal types — a preview

Combined with mapped types, template literal types unlock string-shape APIs that used to need runtime checks. A taste:

type EventMap = {
  click: { x: number; y: number };
  hover: { target: string };
};

type Handlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]:
    (event: E[K]) => void;
};

type UIHandlers = Handlers<EventMap>;
// {
//   onClick: (event: { x: number; y: number }) => void;
//   onHover: (event: { target: string }) => void;
// }

Libraries like React’s typed props, tRPC’s procedure routers, and SQL builders lean heavily on this combination. We mention it here because mapped types are the gateway — as is where the door opens. The conditional types in TypeScript Conditional Types are the other half of the toolkit.

A worked example: form schemas

A schema describes the shape of a form. Derived types describe its values, errors, and touched fields:

type FieldType = 'string' | 'number' | 'boolean';

type Schema = Record<string, FieldType>;

// Type of a field's value
type ValueOf<F extends FieldType> =
  F extends 'string' ? string :
  F extends 'number' ? number :
  boolean;

// All values inferred from the schema
type Values<S extends Schema> = {
  [K in keyof S]: ValueOf<S[K]>;
};

// All errors as optional strings
type Errors<S extends Schema> = {
  [K in keyof S]?: string;
};

// All touched flags
type Touched<S extends Schema> = {
  [K in keyof S]: boolean;
};

const signupSchema = {
  email: 'string',
  age: 'number',
  newsletter: 'boolean',
} as const satisfies Schema;

type SignupValues = Values<typeof signupSchema>;
// { email: string; age: number; newsletter: boolean }

type SignupErrors = Errors<typeof signupSchema>;
// { email?: string; age?: string; newsletter?: string }

One schema, three derived types, zero duplication. Add a field to the schema and every dependent type updates automatically.

Pitfalls

A short list of foot-guns:

  • keyof T on any is string | number | symbol. Constrain T extends object to keep things sensible.
  • Index signatures vs literal keys. Mapping over keyof { [k: string]: V } gives you string, not a specific key set.
  • Distribution sneaks in. When the mapped type’s body uses a conditional with a naked parameter, you may see surprising unions — wrap with [T[K]] extends [...] to lock in.
  • as never removes keys, but only inside the key position. Setting a value to never keeps the key with an unusable type.

Recap

You now know:

  • { [K in keyof T]: ... } walks the keys of T and builds a new object type
  • ? and readonly work as modifiers; +/- add or remove them
  • as remaps keys, including template-literal renames and never-removal
  • Partial, Readonly, Required, Pick, Omit, and Record are all small mapped types
  • Combined with conditional types, mapped types build entire schemas from a single source

Next steps

Conditional types and mapped types are the two engines behind almost every advanced TypeScript pattern. The natural next steps are template literal types in depth, variance and assignability, and the satisfies operator — each a small post that pays off across every project you touch.

Until then, open lib.es5.d.ts in your editor and read the definitions of Partial, Required, Pick, and Omit. You can now read all four at a glance — which means you can write the next one yourself.

Questions or feedback? Email codeloomdevv@gmail.com.