TypeScript Mapped Types

Mapped types are a powerful feature in TypeScript that allows you to create new types based on existing ones by transforming properties in a systematic way. They provide a way to create types that derive from other types, applying transformations to each property.

What Are Mapped Types?

Mapped types allow you to create a new type by iterating through the properties of an existing type and applying transformations to them. This is like using a map function on an array, but for types. Mapped types use the syntax [K in keyof T] to iterate over all properties of an existing type.

Basic Syntax

The basic syntax of a mapped type looks like this:

Basic mapped type syntax

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

This example creates a new type that's identical to the original type. While this specific example doesn't change anything, it demonstrates the pattern that more useful mapped types follow.

Common Mapping Modifiers

Mapped types can use modifiers to change property characteristics:

Optional Properties Modifier (?)

Add or remove the optional modifier from properties:

Optional modifier

// Make all properties optional
type AllOptional<T> = {
  [K in keyof T]?: T[K];
};

// Similar to built-in Partial<T>
interface User {
  id: number;
  name: string;
  email: string;
}

type OptionalUser = AllOptional<User>;
// Equivalent to: { id?: number; name?: string; email?: string; }

Readonly Modifier (readonly)

Add or remove the readonly modifier from properties:

Readonly modifier

// Make all properties readonly
type AllReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Similar to built-in Readonly<T>
interface Config {
  endpoint: string;
  timeout: number;
}

type ReadonlyConfig = AllReadonly<Config>;
// Equivalent to: { readonly endpoint: string; readonly timeout: number; }

Adding and Removing Modifiers

You can also remove modifiers by prefixing them with -:

Removing modifiers

// Remove readonly modifier
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Remove optional modifier
type Required<T> = {
  [K in keyof T]-?: T[K];
};

interface ReadonlyUser {
  readonly id: number;
  readonly name: string;
}

type MutableUser = Mutable<ReadonlyUser>;
// Equivalent to: { id: number; name: string; }

Transforming Property Keys

Mapped types can transform property keys using template literal types:

Transforming keys

// Add a prefix to all property keys
type Prefixed<T, P extends string> = {
  [K in keyof T as `${P}${string & K}`]: T[K];
};

interface Person {
  name: string;
  age: number;
}

type UserPerson = Prefixed<Person, "user">;
// Equivalent to: { username: string; userage: number; }

Filtering Properties with Key Remapping

You can use the as clause to filter or rename properties:

Filtering properties

// Keep only string properties
type StringProps<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Product {
  id: number;
  name: string;
  description: string;
  price: number;
}

type ProductTextProps = StringProps<Product>;
// Equivalent to: { name: string; description: string; }

Conditional Types with Mapped Types

Combining conditional types with mapped types provides even more flexibility:

Conditional mapped types

// Transform the type of each property based on a condition
type TransformProps<T> = {
  [K in keyof T]: T[K] extends number ? T[K] | string : T[K];
};

interface Item {
  id: number;
  name: string;
  qty: number;
}

type FlexibleItem = TransformProps<Item>;
// Equivalent to: { id: number | string; name: string; qty: number | string; }

Practical Examples

Creating a Validator Type

A mapped type can create a validator object that matches the shape of your data:

Validator type

interface FormData {
  username: string;
  email: string;
  age: number;
}

type Validator<T> = {
  [K in keyof T]: (value: T[K]) => boolean;
};

const formValidator: Validator<FormData> = {
  username: (value) => value.length >= 3,
  email: (value) => value.includes("@"),
  age: (value) => value >= 18,
};

Creating Default Values

Generate a type with default values for an interface:

Default values

interface Options {
  timeout?: number;
  retries?: number;
  cacheResults?: boolean;
}

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

const defaultOptions: DefaultValues<Options> = {
  timeout: 1000,
  retries: 3,
  cacheResults: true,
};

API Response Mapper

Create types to map API responses to your application models:

API mapper

interface ApiUser {
  user_id: number;
  user_name: string;
  user_email: string;
}

interface AppUser {
  id: number;
  name: string;
  email: string;
}

// Simplified mapping approach
const mapApiToAppUser = (apiUser: ApiUser): AppUser => ({
  id: apiUser.user_id,
  name: apiUser.user_name,
  email: apiUser.user_email,
});

Advanced Mapped Type Patterns

Deep Mapped Types

You can create mapped types that affect nested properties:

Deep mapped types

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface NestedConfig {
  api: {
    endpoints: {
      users: string;
      posts: string;
    };
    timeout: number;
  };
  debug: boolean;
}

// Makes all properties and nested properties readonly
type ReadonlyNestedConfig = DeepReadonly<NestedConfig>;

Template Literal Key Transformations

Mapped types can use template literals for more complex key transformations:

Template literal transformations

type CamelToSnake<S extends string> = S extends `${infer T}${infer U}`
  ? `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnake<U>}`
  : S;

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

interface UserData {
  userId: number;
  userName: string;
  isActive: boolean;
}

type SnakeUserData = SnakeCaseKeys<UserData>;
// Similar to: { user_id: number; user_name: string; is_active: boolean; }

Best Practices for Mapped Types

  1. Keep it Simple: Overly complex mapped types can be difficult to understand and debug. Start with simple transformations.
  2. Document Your Mapped Types: Add JSDoc comments to explain what your mapped types do, especially for complex transformations.

Documenting mapped types

/**
 * Creates a type with all properties optional and nullable
 */
type Nullable<T> = {
  [K in keyof T]?: T[K] | null;
};
  1. Avoid Excessive Nesting: While deep nested mapped types are possible, they can lead to complexity. Consider breaking them down into smaller, composable pieces.
  2. Use Existing Utility Types: TypeScript provides many built-in utility types that use mapped types under the hood. Prefer these when possible.

Using built-in utility types

// Instead of this:
type CustomPartial<T> = {
  [K in keyof T]?: T[K];
};

// Use the built-in Partial<T>:
type UserPartial = Partial<User>;
  1. Combine with Other Type Features: Mapped types are most powerful when combined with conditional types, template literals, and other TypeScript features.

Combining type features

type RequiredKeys<T> = {
  [K in keyof T]-?: undefined extends T[K] ? never : K;
}[keyof T];

type OptionalKeys<T> = {
  [K in keyof T]-?: undefined extends T[K] ? K : never;
}[keyof T];

interface User {
  id: number;
  name: string;
  email?: string;
  phone?: string;
}

type UserRequiredKeys = RequiredKeys<User>; // "id" | "name"
type UserOptionalKeys = OptionalKeys<User>; // "email" | "phone"

Common Use Cases for Mapped Types

  1. Type Transformation: Converting types from one form to another (e.g., making properties optional, readonly, or nullable).
  2. API Integration: Creating types that match external API shapes or transforming between different naming conventions.
  3. Form Handling: Generating validation types, error message types, or touched field trackers from form data types.
  4. Type-Level Operations: Performing operations like picking, omitting, or filtering properties based on their types.
  5. Schema Definitions: Creating related types from a single source of truth, such as validators, serializers, or default values.

How Mapped Types Compare to Other TypeScript Features

Mapped Types vs. Interfaces

  • Interfaces define a contract for objects to follow
  • Mapped types transform existing types into new ones

Mapped types vs interfaces

// Interface approach
interface ReadonlyUser {
  readonly id: number;
  readonly name: string;
}

// Mapped type approach
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

Mapped Types vs. Type Aliases

  • Type aliases give a name to an existing type
  • Mapped types create new types by transforming existing ones

Mapped types vs type aliases

// Simple type alias
type UserID = number;

// Mapped type
type UserRecord = {
  [K in "id" | "name" | "email"]: string;
};

Mapped Types vs. Utility Types

  • Most utility types in TypeScript are implemented using mapped types
  • Use built-in utility types when possible, create custom mapped types when needed

Common Patterns with Mapped Types

Pick Pattern

Create a new type by picking specific properties:

Pick pattern

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

// Similar to built-in Pick<T, K>

Omit Pattern

Create a new type by omitting specific properties:

Omit pattern

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

// Similar to built-in Omit<T, K>

Record Pattern

Create a type with specified keys and value types:

Record pattern

type RecordType<K extends string | number | symbol, T> = {
  [P in K]: T;
};

// Similar to built-in Record<K, T>

Summary

Mapped types are a powerful feature in TypeScript that allow you to create new types by transforming existing ones. They enable:

  • Property transformation (optional, readonly, different types)
  • Key transformations (renaming, prefixing, filtering)
  • Creating related types from a single source of truth

Combined with other TypeScript features like conditional types, template literals, and infer, mapped types provide a robust toolkit for handling complex type transformations in a clean and maintainable way.

Rather than duplicating type definitions for related concepts, mapped types let you define relationships between types. This makes your code more maintainable and enforces consistency throughout your codebase.

When working with TypeScript, consider mapped types whenever you need to derive new types that are systematic transformations of existing ones. Many common operations can be handled by TypeScript's built-in utility types, but custom mapped types enable you to craft precisely the type transformations your application needs.