Learn

/

Mapped Types

Mapped Types

6 patterns

Key remapping, conditional types, infer keyword, and building types from other types. You'll hit this when you need to transform every property of an existing type in a systematic way.

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

// Manually creating an optional version
interface PartialUser {
  name?: string;
  email?: string;
  age?: number;
}
// Must update both when User changes
interface User {
  name: string;
  email: string;
  age: number;
}

// Manually creating an optional version
interface PartialUser {
  name?: string;
  email?: string;
  age?: number;
}
// Must update both when User changes

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

// Mapped type transforms every property
type PartialUser = {
  [K in keyof User]?: User[K];
};

// Or simply use the built-in:
type AlsoPartialUser = Partial<User>;
// Both auto-update when User changes
interface User {
  name: string;
  email: string;
  age: number;
}

// Mapped type transforms every property
type PartialUser = {
  [K in keyof User]?: User[K];
};

// Or simply use the built-in:
type AlsoPartialUser = Partial<User>;
// Both auto-update when User changes
Why avoid

Manually duplicating an interface with optional properties creates two types that must be kept in sync. Adding a new field to User without updating PartialUser leads to inconsistencies that the compiler cannot detect. Mapped types derive one type from another, eliminating this drift.

Why prefer

Mapped types iterate over keys of an existing type using [K in keyof T] and can add or remove modifiers like ? and readonly. This is exactly how the built-in Partial utility type works. Changes to the source type automatically propagate to the mapped type.

TypeScript: Mapped Types
Avoid
interface Config {
  host: string;
  port: number;
  debug: boolean;
}

// Manually creating setter functions
interface ConfigSetters {
  setHost: (val: string) => void;
  setPort: (val: number) => void;
  setDebug: (val: boolean) => void;
}
interface Config {
  host: string;
  port: number;
  debug: boolean;
}

// Manually creating setter functions
interface ConfigSetters {
  setHost: (val: string) => void;
  setPort: (val: number) => void;
  setDebug: (val: boolean) => void;
}

Prefer
interface Config {
  host: string;
  port: number;
  debug: boolean;
}

type Setters<T> = {
  [K in keyof T
    as `set${Capitalize<string & K>}`
  ]: (value: T[K]) => void;
};

type ConfigSetters = Setters<Config>;
// { setHost: (value: string) => void;
//   setPort: (value: number) => void;
//   setDebug: (value: boolean) => void }
interface Config {
  host: string;
  port: number;
  debug: boolean;
}

type Setters<T> = {
  [K in keyof T
    as `set${Capitalize<string & K>}`
  ]: (value: T[K]) => void;
};

type ConfigSetters = Setters<Config>;
// { setHost: (value: string) => void;
//   setPort: (value: number) => void;
//   setDebug: (value: boolean) => void }
Why avoid

Manually writing setter interfaces requires updating three places when a config property changes: the Config type, the setter type, and the implementation. Key remapping generates the setter type automatically, so adding a new config field produces the correct setter signature with no extra work.

Why prefer

The as clause in mapped types lets you remap keys to new names. Combined with template literal types and Capitalize, you can automatically generate setter method names from property keys. The value types stay correctly linked to their original properties.

TypeScript: Key Remapping via as
Avoid
interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
  age: number;
}

// Manually picking string properties
interface StringFields {
  name: string;
  email: string;
}
// Breaks if User changes
interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
  age: number;
}

// Manually picking string properties
interface StringFields {
  name: string;
  email: string;
}
// Breaks if User changes

Prefer
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
  age: number;
}

type UserStringKeys = StringKeys<User>;
// "name" | "email"

type UserStrings = Pick<User, UserStringKeys>;
// { name: string; email: string }
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
  age: number;
}

type UserStringKeys = StringKeys<User>;
// "name" | "email"

type UserStrings = Pick<User, UserStringKeys>;
// { name: string; email: string }
Why avoid

Manually picking properties by hand creates a separate type that must be maintained whenever the source type changes. Adding a new string property to User would not appear in StringFields unless you remember to add it. Type-level filtering keeps the extracted type in sync automatically.

Why prefer

Conditional types inside mapped types can filter keys by their value type. Mapping non-matching keys to never and then indexing with [keyof T] produces a union of only the matching keys. Combined with Pick, this extracts a subset of properties based on their types.

TypeScript: Conditional Types
Avoid
// Manually defining return types
type UserFromAPI = {
  name: string;
  email: string;
};

async function fetchUser(): Promise<UserFromAPI> {
  return api.get("/user");
}

// Must keep UserFromAPI in sync with
// what the function actually returns
type FetchedUser = UserFromAPI;
// Manually defining return types
type UserFromAPI = {
  name: string;
  email: string;
};

async function fetchUser(): Promise<UserFromAPI> {
  return api.get("/user");
}

// Must keep UserFromAPI in sync with
// what the function actually returns
type FetchedUser = UserFromAPI;

Prefer
async function fetchUser() {
  const res = await api.get<{
    name: string;
    email: string;
  }>("/user");
  return res.data;
}

// Extract the resolved type from
// the function's return type
type UnwrapPromise<T> =
  T extends Promise<infer U> ? U : T;

type FetchedUser = UnwrapPromise<
  ReturnType<typeof fetchUser>
>;
// { name: string; email: string }
// Auto-updates when fetchUser changes
async function fetchUser() {
  const res = await api.get<{
    name: string;
    email: string;
  }>("/user");
  return res.data;
}

// Extract the resolved type from
// the function's return type
type UnwrapPromise<T> =
  T extends Promise<infer U> ? U : T;

type FetchedUser = UnwrapPromise<
  ReturnType<typeof fetchUser>
>;
// { name: string; email: string }
// Auto-updates when fetchUser changes
Why avoid

Defining a separate type alias and manually keeping it in sync with the function's actual return type is fragile. If the API response shape changes in the function but not in the type alias, the compiler cannot detect the mismatch. Using infer with ReturnType extracts the type from the source of truth.

Why prefer

The infer keyword lets you declare a type variable within a conditional type and extract part of a matched type. Here it unwraps the Promise to get the resolved value type. Combined with ReturnType, this derives the type directly from the function, so changes to fetchUser automatically flow to FetchedUser.

TypeScript: Inferring Within Conditional Types
Avoid
type NonNullableFields<T> = {
  [K in keyof T]: Exclude<T[K], null | undefined>;
};

// But what about nested objects?
interface Form {
  name: string | null;
  address: {
    city: string | null;
    zip: string | undefined;
  } | null;
}

type Clean = NonNullableFields<Form>;
// address.city is still string | null
type NonNullableFields<T> = {
  [K in keyof T]: Exclude<T[K], null | undefined>;
};

// But what about nested objects?
interface Form {
  name: string | null;
  address: {
    city: string | null;
    zip: string | undefined;
  } | null;
}

type Clean = NonNullableFields<Form>;
// address.city is still string | null

Prefer
type DeepNonNullable<T> =
  T extends object
    ? { [K in keyof T]-?:
        DeepNonNullable<NonNullable<T[K]>>
      }
    : NonNullable<T>;

interface Form {
  name: string | null;
  address: {
    city: string | null;
    zip: string | undefined;
  } | null;
}

type Clean = DeepNonNullable<Form>;
// { name: string;
//   address: { city: string; zip: string } }
type DeepNonNullable<T> =
  T extends object
    ? { [K in keyof T]-?:
        DeepNonNullable<NonNullable<T[K]>>
      }
    : NonNullable<T>;

interface Form {
  name: string | null;
  address: {
    city: string | null;
    zip: string | undefined;
  } | null;
}

type Clean = DeepNonNullable<Form>;
// { name: string;
//   address: { city: string; zip: string } }
Why avoid

A shallow mapped type only transforms the top-level properties. Nested objects retain their original nullability, which means you still need manual null checks inside deeply nested structures. Recursive conditional types solve this by applying the transformation at every depth.

Why prefer

Distributive conditional types (T extends object ? ...) distribute over union members. By recursively applying the transformation, DeepNonNullable strips null and undefined from every level of a nested type. The -? modifier removes optional markers as well.

TypeScript: Distributive Conditional Types
Avoid
interface Config {
  db: { host: string; port: number };
  cache: { ttl: number };
}

// Manually listing all possible paths
type ConfigPath =
  | "db"
  | "db.host"
  | "db.port"
  | "cache"
  | "cache.ttl";
// Must update when Config changes
interface Config {
  db: { host: string; port: number };
  cache: { ttl: number };
}

// Manually listing all possible paths
type ConfigPath =
  | "db"
  | "db.host"
  | "db.port"
  | "cache"
  | "cache.ttl";
// Must update when Config changes

Prefer
type Paths<T, Prefix extends string = ""> =
  T extends object
    ? {
        [K in keyof T & string]:
          | `${Prefix}${K}`
          | Paths<T[K], `${Prefix}${K}.`>
      }[keyof T & string]
    : never;

interface Config {
  db: { host: string; port: number };
  cache: { ttl: number };
}

type ConfigPath = Paths<Config>;
// "db" | "db.host" | "db.port"
// | "cache" | "cache.ttl"
type Paths<T, Prefix extends string = ""> =
  T extends object
    ? {
        [K in keyof T & string]:
          | `${Prefix}${K}`
          | Paths<T[K], `${Prefix}${K}.`>
      }[keyof T & string]
    : never;

interface Config {
  db: { host: string; port: number };
  cache: { ttl: number };
}

type ConfigPath = Paths<Config>;
// "db" | "db.host" | "db.port"
// | "cache" | "cache.ttl"
Why avoid

Manually enumerating dot-notation paths is tedious and impossible to keep in sync with a changing type. A recursive type computes the full set of valid paths from the source type, enabling type-safe deep access patterns like get(config, 'db.host') without maintaining a separate path list.

Why prefer

Recursive mapped types can generate dot-notation path strings for any nested object structure. By iterating over keys and recursing into object values, the type builds a union of all valid paths. Adding a new nested field to Config automatically produces the correct path string.

TypeScript: Creating Types from Types