Mapped Types: Creating New Types Dynamically

Table of Contents

  • Introduction
  • What Are Mapped Types?
  • Basic Syntax of Mapped Types
  • Common Use Cases of Mapped Types
  • Creating Read-Only and Writable Types
  • Using Mapped Types with keyof and in
  • Mapping with Optional and Required Properties
  • Advanced Mapped Types: Nested Mapped Types
  • Practical Examples
    • Example 1: Creating Read-Only Properties
    • Example 2: Changing Property Types Dynamically
    • Example 3: Mapping Object Keys
  • Best Practices for Using Mapped Types
  • Conclusion

Introduction

Mapped types are one of the most powerful features of TypeScript, allowing you to dynamically create new types by transforming or modifying existing ones. With mapped types, you can iterate over the keys of a given type and transform the properties according to certain rules. They are especially useful for reducing repetitive code and creating reusable utilities for complex types.

In this article, we will dive deep into mapped types, discussing their syntax, common use cases, and advanced techniques. By the end, you’ll understand how to create new types dynamically and apply them to various scenarios.


What Are Mapped Types?

Mapped types allow you to create a new type by iterating over the properties of an existing type. The transformation can be as simple as making all properties optional, read-only, or even changing the type of properties dynamically. Mapped types offer flexibility and help you reduce redundancy in type definitions.

The basic syntax of a mapped type is:

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

Here:

  • T is the type you’re iterating over.
  • keyof T represents the keys of the type T.
  • SomeTransformation<T[K]> defines how each property should be transformed.

Basic Syntax of Mapped Types

To understand how mapped types work, let’s start with a basic example:

type Person = {
name: string;
age: number;
};

type ReadOnlyPerson = {
readonly [K in keyof Person]: Person[K];
};

Here:

  • Person is a simple object type with two properties: name and age.
  • ReadOnlyPerson is a new type where all properties of Person are transformed to be read-only using the readonly keyword.

This creates a new type where the properties are immutable:

const person: ReadOnlyPerson = {
name: "John",
age: 30
};

person.name = "Jane"; // Error: Cannot assign to 'name' because it is a read-only property.

In this case, the mapped type dynamically applies the readonly modifier to all properties of Person.


Common Use Cases of Mapped Types

Mapped types are incredibly useful in a variety of situations, including:

  • Immutability: Making all properties of a type read-only or writable.
  • Optional Properties: Transforming all properties into optional ones.
  • Mapping Property Types: Changing the types of certain properties dynamically.
  • Creating Flexible Object Structures: Defining structures that adapt based on certain conditions.

By using mapped types, you can create flexible and reusable patterns for various common tasks, reducing code duplication.


Creating Read-Only and Writable Types

One of the most common use cases for mapped types is making all properties of an object either read-only or writable. TypeScript provides built-in utility types like Readonly and Writable, but you can easily recreate these behaviors using mapped types.

Example: Creating a Read-Only Type

type Person = {
name: string;
age: number;
};

type ReadOnlyPerson = {
readonly [K in keyof Person]: Person[K];
};

Here, ReadOnlyPerson will have all properties of Person marked as readonly. You can use this technique to apply immutability to any type.

Example: Creating a Writable Type

type ReadOnlyPerson = {
readonly name: string;
readonly age: number;
};

type WritablePerson = {
-readonly [K in keyof ReadOnlyPerson]: ReadOnlyPerson[K];
};

In this example, the WritablePerson type removes the readonly modifier from the properties of ReadOnlyPerson, making them writable again.


Using Mapped Types with keyof and in

In a mapped type, keyof is used to get the keys of a type, and in is used to iterate over those keys. This combination makes it easy to map over every property of an object and apply a transformation.

Example: Using keyof and in for Mapping

type Person = {
name: string;
age: number;
};

type MappedPerson = {
[K in keyof Person]: string;
};

Here:

  • We use keyof Person to get all the keys of the Person type (name and age).
  • We iterate over these keys using in, and for each key, we set its type to string.

As a result, MappedPerson will have all properties of Person with the type string:

type MappedPerson = {
name: string;
age: string;
};

This technique allows you to transform all properties of a type in a concise and reusable way.


Mapping with Optional and Required Properties

Mapped types also allow you to create types where all properties are either optional or required.

Example: Making All Properties Optional

type Person = {
name: string;
age: number;
};

type OptionalPerson = {
[K in keyof Person]?: Person[K];
};

Here, the OptionalPerson type has all properties of Person marked as optional.

Example: Making All Properties Required

type OptionalPerson = {
name?: string;
age?: number;
};

type RequiredPerson = {
[K in keyof OptionalPerson]-?: OptionalPerson[K];
};

In this example:

  • RequiredPerson is a mapped type that makes all properties of OptionalPerson required again.

Advanced Mapped Types: Nested Mapped Types

You can use mapped types to create deeply nested types as well. This allows you to transform complex nested structures dynamically.

Example: Nested Mapped Types

type Person = {
name: string;
address: {
street: string;
city: string;
};
};

type ReadOnlyPerson = {
readonly [K in keyof Person]: Person[K] extends object ? ReadOnlyPerson[Extract<keyof Person[K], string>] : Person[K];
};

In this example:

  • The mapped type recursively marks all properties of the nested address object as readonly.

This type transforms every property in the object and its nested objects, providing deep immutability.


Practical Examples

Example 1: Creating Read-Only Properties

type Person = {
name: string;
age: number;
};

type ReadOnlyPerson = {
readonly [K in keyof Person]: Person[K];
};

In this example:

  • ReadOnlyPerson will have all properties of Person as readonly.

Example 2: Changing Property Types Dynamically

type Person = {
name: string;
age: number;
};

type MappedPerson = {
[K in keyof Person]: string;
};

Here:

  • MappedPerson dynamically changes all properties of Person to string type.

Example 3: Mapping Object Keys

type Person = {
name: string;
age: number;
};

type MappedPerson = {
[K in keyof Person]: Person[K] extends string ? number : Person[K];
};

In this case:

  • We change the type of name to number if the property type is string.

Best Practices for Using Mapped Types

  1. Keep It Simple: While mapped types are powerful, avoid overly complex transformations that make the code hard to understand and maintain.
  2. Use Built-in Utility Types When Possible: TypeScript has many built-in utility types like Readonly, Partial, and Required. Use these when they fit your needs, as they are well-tested and optimized.
  3. Be Careful with Recursive Mapped Types: When dealing with deeply nested structures, use recursive mapped types carefully to avoid infinite recursion or performance issues.

Conclusion

Mapped types are a versatile feature in TypeScript that allow you to create new types by transforming existing ones. They are essential for creating reusable utilities, reducing redundancy, and improving the maintainability of your code.

By mastering mapped types, you can dynamically apply changes to object types, such as making properties optional, required, or read-only, as well as altering the types of properties. Whether you’re working with simple objects or deeply nested structures, mapped types provide the flexibility to adapt your types to your needs.