Skip to lesson

learningtypescript.org / advanced / 21-mapped-types · lesson 21 of 25

TL;DR

Learn TypeScript mapped types to transform, filter, and reshape types programmatically. Build your own Partial, Required, and Readonly.

Key concepts

  • TypeScript mapped types
  • mapped types tutorial
  • keyof TypeScript
  • type transformations

Mapped Types

TypeScript's built-in utility types like Partial<T>, Required<T>, and Readonly<T> feel almost magical — they transform entire type structures with a single generic. Mapped types are the mechanism behind all of them. Once you understand mapped types, you can write your own type transformations that adapt to any shape of data.

A mapped type iterates over the keys of another type and produces a new type based on rules you define. Think of it as a map() function — but for types instead of arrays.

The Basic Syntax

The core syntax of a mapped type uses in keyof:

interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

// Create a version where every field is a string
type Stringified<T> = {
  [K in keyof T]: string;
};

type StringProduct = Stringified<Product>;
// {
//   id: string;
//   name: string;
//   price: string;
//   inStock: string;
// }

const display: StringProduct = {
  id: "42",
  name: "Widget",
  price: "$9.99",
  inStock: "Yes",
};

console.log(display);

[K in keyof T] says: "for each key K that exists in T, create a property named K". The value type on the right side determines what each property holds. Right now we're replacing every value with string, which isn't very useful — but it illustrates the iteration.

Preserving the Original Types

The real power comes from using T[K] — an indexed access type — to look up each property's original type:

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

// Make every property optional — this is exactly how Partial<T> works internally
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make every property readonly — this is how Readonly<T> works
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

type PartialUser = MyPartial<User>;
type ReadonlyUser = MyReadonly<User>;

const draft: PartialUser = { name: "Alice" }; // all fields are optional

const frozen: ReadonlyUser = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  isAdmin: false,
};

// frozen.name = "Bob"; // Error: Cannot assign to 'name' — it is read-only

console.log("Draft:", draft);
console.log("Frozen:", frozen);

T[K] threads the original value type through unchanged. Adding ? makes every property optional. Adding readonly makes every property immutable. The keys and value types are preserved — only the modifiers change.

Adding and Removing Modifiers

You can not only add modifiers, but also remove them using the - prefix:

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

// Remove readonly — makes all properties mutable
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Remove optional — makes all properties required
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

type MutableConfig = Mutable<Config>;
type RequiredConfig = MyRequired<Config>;

// MutableConfig allows reassignment
const cfg: MutableConfig = { host: "localhost", port: 3000 };
cfg.host = "example.com"; // Now allowed!

// RequiredConfig forces all fields to be present
const full: RequiredConfig = {
  host: "example.com",
  port: 443,
  debug: true, // Can no longer be omitted
};

console.log("Mutable config:", cfg);
console.log("Required config:", full);

The - prefix removes a modifier. -readonly strips read-only enforcement, -? strips optionality. The TypeScript standard library uses both of these patterns to implement Required<T> and to build mutable versions of types.

Remapping Keys with as

TypeScript 4.1 introduced key remapping, letting you rename keys as part of the mapped type using an as clause:

interface ApiResponse {
  user_id: number;
  first_name: string;
  last_name: string;
  created_at: string;
}

// Recursively convert snake_case to camelCase using template literal types
type CamelCase<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<CamelCase<Tail>>}`
    : S;

type CamelCaseKeys<T> = {
  [K in keyof T as CamelCase<string & K>]: T[K];
};

type CamelResponse = CamelCaseKeys<ApiResponse>;
// {
//   userId: number;
//   firstName: string;
//   lastName: string;
//   createdAt: string;
// }

const response: CamelResponse = {
  userId: 1,
  firstName: "Alice",
  lastName: "Smith",
  createdAt: "2024-01-01",
};

console.log(response);

The as clause lets you transform the key using any type-level operation — template literals, Capitalize, Lowercase, Uncapitalize, or a conditional expression. This is how you build adapters between different naming conventions at the type level.

Filtering Keys with never

When you remap a key to never, TypeScript drops it from the resulting type entirely. This lets you filter properties based on their value types:

interface Employee {
  id: number;
  name: string;
  salary: number;
  department: string;
  isActive: boolean;
}

// Keep only properties whose value type extends ValueType
type PickByValue<T, ValueType> = {
  [K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};

type StringFields = PickByValue<Employee, string>;
// { name: string; department: string }

type NumberFields = PickByValue<Employee, number>;
// { id: number; salary: number }

const textOnly: StringFields = {
  name: "Bob",
  department: "Engineering",
};

const numsOnly: NumberFields = {
  id: 7,
  salary: 95000,
};

console.log("String fields:", textOnly);
console.log("Number fields:", numsOnly);

Remapping a key to never is the idiomatic way to filter mapped types. The conditional T[K] extends ValueType ? K : never evaluates per-key at the type level — keys that produce never simply disappear from the output.

Try It Yourself

Build a FormState<T> mapped type that wraps any form's data model. Each field should track its current value, whether it has been touched by the user, and any validation error message:

interface LoginForm {
  email: string;
  password: string;
  rememberMe: boolean;
}

type FieldState<V> = {
  value: V;
  touched: boolean;
  error: string | null;
};

// Each key in T maps to a FieldState wrapping that key's original type
type FormState<T> = {
  [K in keyof T]: FieldState<T[K]>;
};

// FormState<LoginForm> produces:
// {
//   email:      { value: string;  touched: boolean; error: string | null };
//   password:   { value: string;  touched: boolean; error: string | null };
//   rememberMe: { value: boolean; touched: boolean; error: string | null };
// }

const loginState: FormState<LoginForm> = {
  email:      { value: "",    touched: false, error: null },
  password:   { value: "",    touched: false, error: null },
  rememberMe: { value: false, touched: false, error: null },
};

function touchField<T>(state: FormState<T>, field: keyof T): FormState<T> {
  return {
    ...state,
    [field]: { ...state[field], touched: true },
  };
}

function setError<T>(
  state: FormState<T>,
  field: keyof T,
  error: string | null
): FormState<T> {
  return {
    ...state,
    [field]: { ...state[field], error },
  };
}

let state = touchField(loginState, "email");
state = setError(state, "email", "Please enter a valid email address");

console.log("email touched:", state.email.touched);
console.log("email error:", state.email.error);
console.log("password touched:", state.password.touched);

Try extending FieldState to add a dirty boolean that tracks whether the current value differs from an initial snapshot, or add an isDirty<T> helper that returns true if any field in a FormState<T> has been modified.

Key Takeaways

  • Mapped types use { [K in keyof T]: ... } to iterate over the keys of an existing type and produce a new type with transformed structure
  • T[K] (indexed access) preserves each property's original value type as you change the surrounding modifiers
  • Modifiers can be added (readonly, ?) or removed (-readonly, -?) in the mapped type syntax — all of TypeScript's core utility types are built this way
  • The as clause enables key remapping using template literals, Capitalize, Lowercase, and conditional type expressions
  • Remapping a key to never filters that property out of the resulting type entirely — this is the standard pattern for value-type-based filtering
  • All built-in utility typesPartial, Required, Readonly, Record, Pick — are implemented using mapped types; understanding the pattern demystifies them and lets you write your own

Pro Tip: When you find yourself copy-pasting a type and changing one thing about every property — making them all optional, all nullable, all readonly — that's the signal to reach for a mapped type. A well-named generic mapped type applied once is far easier to maintain than five manually-synced variant types scattered across your codebase. Name your mapped types after what they represent (FormState<T>, Patchable<T>, Serialized<T>), not how they work (WithOptionalFields<T>).

Next Steps

You've mastered type-level transformations — now it's time to apply them in a full-stack framework. Next, you'll learn how TypeScript integrates with Next.js: typed page props, generic API wrappers, server action results, and building a typed data layer.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.