Susan Potter
software: Created / Updated

TypeScript's Mapped Types

A TypeScript code example of creating a new type using a type mapping computation to make properties optional with the same type as the underlying type.
A TypeScript code example of creating a new type using a type mapping computation to make properties optional with the same type as the underlying type.

In this article, we'll take a whirlwind tour through the fascinating world of TypeScript and its type system. Hold onto your hats and get ready to unleash the power of mapped types!

Mapped types are the true sorcerers of TypeScript especially when used in conjunction with keyof and conditional types. They wield the power to transform and shape types with a flick of their magical wands. Mapped types allow you to redefine existing types, making them dance to your tune.

Why Mapped Types Matter

Mapped types give you the power to streamline your code, enhance its clarity, and catch those sneaky bugs before they wreak havoc. They enable you to write robust, maintainable code that makes your fellow developers nod in gratitude (when used just right).

To illustrate the utility of mapped types we will show how to use a mapped type commonly used in TypeScript already: Pick.

Let us start with the trusted Person type example developers overuse ad nauseam to show what it does:

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

Sometimes we only want to pass a subset of the Person type's properties values to functions. In JavaScript we either pass the entire object value regardless or we pass the properties one-by-one as separate arguments.

In TypeScript we can compute a new type that plucks (or "picks") a specific subset of properties names to collate in one type definition.

In the example below we pluck (or "pick") the name and age properties from the Person type defined above.

// Behold, the power of Pick!
type PersonBasicInfo =
  Pick<Person, 'name' | 'age'>;

The type computed by Pick<Person, 'name' | 'age'> looks like this:

{
  name: string;
  age: number;
}

With the power of Pick we summoned the PersonBasicInfo type containing only the name and age properties.

Mapped Types Syntax

It's time to decode the incantations and unravel the key components that make mapped types spellbinding.

We define mapped types using angle brackets < > and curly braces { }. Let's demystify the basic syntax step-by-step:

type MappedType = {
  [Property in ExistingType]: NewProperty
};
  • MappedType: This is the name you give to your created mapped type. It can be any valid type name you choose.
  • [Property in ExistingType]: This is the magical syntax incantation that sets up your mapped type. Property represents each property in the existing type that you want to manipulate. ExistingType is the original type from which you're deriving the mapped type.
  • NewProperty: This is where your creativity comes into play. You define the transformed version of each property in the mapped type.

The Components of Mapped Types

Property: The Building Block of Magic

In this example, we'll transform the properties of an existing type, Person, into optional properties using mapped types:

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

type OptionalPerson = {
  [Property in keyof Person]?: Person[Property]
};
  • Person: Our existing type with properties name, age, and address.
  • OptionalPerson: The name of our mapped type. We use the keyof operator to iterate over each property in Person and create optional properties in OptionalPerson.

The resulting type looks like the following:

type OptionalPerson = {
  name?: string | undefined;
  age?: number | undefined;
  address?: string | undefined;
  email?: string | undefined;
};

Examples of valid constructions of this type include:

// Note: Shakespeare doesn't have an email
const shakespeare: OptionalPerson = {
  name: "William Shakespeare",
  age: 52,
  address: "Stratford-upon-Avon, England",
};

// After dying Shakespeare's address changed
const deathChanges: OptionalPerson = {
  address: "Church of the Holy Trinity, Stratford-upon-Avon",
};

const updatedShakespeare: OptionalPerson = {
  ...shakespeare,
  ...deathChanges
};

ExistingType: The Source of Power

Let's explore another example that transforms the property types of an existing type:

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

type PersonWithOptionalProps = {
  [Property in keyof Person]: Person[Property] | undefined
};
  • Person: Our existing type with properties name and age.
  • PersonWithOptionalProps: Our mapped type that retains the same properties as Person, but with transformed property types. We use the keyof operator to iterate over each property in Person and create a union type with undefined.

You might be wondering what is the difference between OptionalPerson and PersonWithOptionalProps? Let's look at the computed type definition:

type PersonWithOptionalProps = {
    name: string | undefined;
    age: number | undefined;
    address: string | undefined;
    email: string | undefined;
};

Note that the property names do not have the ? suffix. What does this mean in pracitce? Let's try to set the type to the object literal set to the shakespeare constant above:

const shakespeare2: PersonWithOptionalProps = {
  name: "William Shakespeare",
  age: 52,
  address: "Stratford-upon-Avon, England",
}; // Errors see message below

Now we get an error! Let's take a look:

Property 'email' is missing in type
         '{ name: string;
            age: number;
            address: string;
          }'
but required in type 'PersonWithOptionalProps'.

So what this is saying is that we do need to set the missing email property. To model our requirements, we can set it to undefined to denote that shakespeare2 has no email property value.

const shakespeare2: PersonWithOptionalProps = {
  name: "William Shakespeare",
  age: 52,
  address: "Stratford-upon-Avon, England",
  email: undefined,
};

The above now typechecks and the original error applies to any missing property. We needed to explicitly set the missing properties from Person to undefined.

Modifying Properties: Infusing Your Magic

Let's delve into an example where we omit the modifiers of properties:

type ReadonlyPerson = {
  readonly name: string,
  readonly age: number,
  readonly address: string,
  readonly email: string,
};

type MutablePerson = {
  -readonly [Property in keyof ReadonlyPerson]: ReadonlyPerson[Property]
};
  • Person: Our existing type with properties name and age.
  • MutablePerson: Our mapped type that mirrors the properties of Person, but removes the readonly modifier. We use the -readonly syntax to update the property modifiers within the mapped type.

MutablePerson computes to:

type MutablePerson = {
    name: string;
    age: number;
    address: string;
    email: string;
};

No readonly, mom!

Adding Properties

Let's explore one last example to showcase the versatility of mapped types:

type Circle = {
  radius: number;
};

type Cylinder = {
  radius: number;
  height: number;
};

type CalculateVolume<T> = {
  [Property in keyof T]: T[Property];
} & { volume: number };

function calculateCylinderVolume(
  cylinder: Cylinder
): CalculateVolume<Cylinder> {
  const volume =
    Math.PI * cylinder.radius ** 2 * cylinder.height;
  return { ...cylinder, volume };
}
  • Circle: Our existing type representing a circle with a radius property.
  • Cylinder: Another existing type representing a cylinder with radius and height properties.
  • CalculateVolume<T>: Our mapped type that extends any existing type T. It retains the properties of T and adds a new volume property of type number.
  • calculateCylinderVolume: A function that takes a Cylinder object, calculates its volume, and returns a CalculateVolume<Cylinder> object with the original properties of Cylinder and the newly added volume property.

With these examples, you've witnessed the magic of mapped types. We have manipulated properties, modified modifiers, transformed property types and added new properties.

A Deeper Dive!

In this section, we explore the mechanics of property transformation and mapped types to reshape our types with a dash of enchantment.

Modifying Property Modifiers

Making Properties Optional: Partial type

Imagine a world where properties have the freedom to choose their destiny, where they can be present or absent at their whim. Enter the wondrous Partial type! Let's demystify the derivation of Partial from first principles:

type Partial<T> = {
  [Property in keyof T]?: T[Property]
};
  • Partial<T>: computes a new type that mirrors the original type T, but grants properties the ability to become optional using the ? modifier. It's like giving properties a ticket to the land of freedom.

Let's witness the power of Partial in action:

interface Wizard {
  name: string;
  spells: string[];
}

type PartialWizard = Partial<Wizard>;

const wizard: PartialWizard = {}; // Property "name" and "spells" become optional
  • PartialWizard: Our transformed type derived from Wizard using the Partial mapped type. Now, properties like name and spells have the choice to be present or absent, granting flexibility and easing our coding journey.

Making Properties Read-Only: Readonly type

In the land of code, where properties roam free, some properties prefer to stand tall and unchangeable, like statues of wisdom. Enter the majestic Readonly type, which bestows the power of immutability upon properties. Let's unlock the secrets of Readonly:

type Readonly<T> = {
  readonly [Property in keyof T]: T[Property]
};
  • Readonly<T>: The alchemical mixture that creates a new type with the same properties as the original T, but marked as readonly. It's like encasing properties in an unbreakable spell, ensuring they remain untouchable.

Behold the might of Readonly in action:

interface Potion {
  name: string;
  ingredients: string[];
};

const potion: Potion = {
  name: "Elixir of Eternal Youth",
  ingredients: ["Unicorn tears", "Moonlight essence"],
};

potion.name = "Forbidden Potion"; // Works
console.debug(potion.name); // prints "Forbidden Potion"

type ReadonlyPotion = Readonly<Potion>;

const ropotion: ReadonlyPotion = {
  name: "Elixir of Eternal Youth",
  ingredients: ["Unicorn tears", "Moonlight essence"],
};

ropotion.name = "Forbidden Potion"; // Error! Property "name" is read-only
  • ReadonlyPotion: Our transformed type created from Potion using the Readonly mapped type. Now, properties are guarded against any attempts to change them. This ensures their immutability and preserves their original value.

Excluding Properties: Exclude type

Sometimes we need to separate the chosen from the unwanted, to exclude certain elements from our type sorcery. Enter the extraordinary Exclude type, capable of removing specific types from a union. Let's uncover the essence of Exclude:

type Exclude<T, U> = T extends U ? never : T;
Exclude<T, U>
The spell that removes types present in U from the union of types T. Like a forcefield that shields our types from unwanted members.

Let's witness the might of Exclude:

type Elements = "Fire" | "Water" | "Air" | "Earth";
type ExcludedElements = Exclude<Elements, "Fire" | "Air">;

const element: ExcludedElements = "Water"; // Success! Excludes "Fire" and "Air"
const forbiddenElement: ExcludedElements = "Fire"; // Error! "Fire" is excluded
ExcludedElements
Our transformed type, derived from Elements using the Exclude mapped type. With the power of Exclude, we've excluded the elements "Fire" and "Air" from our new type, allowing only "Water" and "Earth" to remain.

Transforming Property Types

Modifying Property Types: Pick and Record types

Picture a symphony where notes dance and melodies intertwine. In the world of TypeScript, we have the harmonious Pick and Record types. They pluck or transform property types. Let's explore their utility:

Pick<T, K>
computes a new type selecting specific properties K from the original type T.
Record<K, T>
computes a new type by mapping each property key K to a corresponding property type T.

Let's review some examples of Pick and Record:

interface Song {
  title: string;
  artist: string;
  duration: number;
}

type SongTitle = Pick<Song, "title">;
type SongDetails = Record<"title" | "artist", string>;

// Only "title" property is allowed
const songTitle: SongTitle = { title: "In the End" };
// Only "title" and "artist" properties are allowed
const songDetails: SongDetails = { title: "Bohemian Rhapsody", artist: "Queen" };
SongTitle
Our transformed type derived from Song using the Pick mapped type. It selects only the title property, allowing us to focus on the song title.
SongDetails
Our transformed type derived from the key "title" | "artist" and the type string using the Record mapped type. It maps each property key to the type string, creating a type that captures the song title and artist.

Replacing Property Types: Mapped Types with conditional types

Now, we explore how mapped types can work with conditional types to transform property types:

Conditional Types
A type-level capability to adapt based on conditions. I wrote about conditional types in a previous post, illustrating good and bad examples.

Let's observe conditional transformations with code examples:

type Pet = "Cat" | "Dog" | "Bird";
type Treats<T extends Pet> = T extends "Cat" ? string[] : T extends "Dog" ? number : boolean;

const catTreats: Treats<"Cat"> = ["Salmon Treats", "Catnip"]; // Array of strings
const dogTreats: Treats<"Dog"> = 5; // Number
const birdTreats: Treats<"Bird"> = true; // Boolean
  • Treats<T> : Our transformed type, where the property type varies based on the condition in the conditional type. The resulting type adapts to the pet's type from the input, offering an array of strings for a cat, a number for a dog, and a boolean for a bird.

Above we observed the powers of Pick, Record, and the fusion of mapped types with conditional types. Using these TypeScript capabilities, we can reduce boilerplate and express the problem domain more directly.

So, grab your wands, summon your creativity, and let the transformation of properties and property types begin! But be judicuous.

Tips, Tricks, and Best Practices: Unleashing the Magic Safely

Tips for Working with Mapped Types: Navigating the Magical Realm

As we traverse mapped types, it's essential to keep a few tips and tricks up our sleeves.

Consider performance implications
  • Mind the size and complexity of your types.
  • Large mappings can lead to longer compile times and increased memory usage.
  • Strike a balance between expressiveness and performance for optimal code execution.
Beware of limitations and pitfalls
  • Mapped types cannot create new properties that don't exist in the original type.
  • Complex mappings or recursive transformations may result in convoluted and hard-to-read code.
  • Stay vigilant and explore alternative strategies when faced with these challenges.
Master complex mappings and type inference challenges
  • Embrace utility types like keyof and conditional types.
  • Harness their power to navigate intricate mappings and overcome type inference hurdles.
  • Experiment, iterate, and tap into the vast resources of the TypeScript documentation and developer communities.

With these suggestions, you can leverage TypeScript's mapped types while avoiding common pitfalls.

Best Practices and Guidelines: Taming the Magic for Large-Scale Projects

To tame the magic of mapped types in large projects, follow these best practices:

Organize and maintain mapped types
  • Group related types together.
  • Create dedicated type files.
  • Provide clear documentation.

This fosters maintainability and enables effective use by fellow wizards.

Ensure type safety and compatibility
  • Use type guards and strict null checks.
  • Perform thorough testing.

Validate the safety and compatibility of your mapped types. Integrate comprehensive yet meaningful tests to check their usage is as expected.

Leverage mapped types for clarity and maintainability
  • Create reusable abstractions.
  • Enforce consistent patterns.
  • Reduce duplication.
  • Avoid reinventing mapped types already provided by TypeScript.

Harness mapped types to enhance code readability and simplify maintenance tasks.

Wrapping it up

Throughout this article we embarked on a wild ride through the enchanted land of mapped types in TypeScript. We've seen their transformative abilities, from modifying property modifiers to reshaping property types. We've explored tips, tricks, and better practices for mapped types.

Unleash your imagination and continue exploring the enormous possibilities that mapped types offer. Yet avoid reinventing constructs as TypeScript already defines utility types for common uses.

Wield the magic of mapped types with care, always striving for clarity, maintainability, and type safety but do not overuse. Your future teammates will thank you later.

If you enjoyed this content, please consider sharing this link with a friend, following my GitHub, Twitter/X or LinkedIn accounts, or subscribing to my RSS feed.