DEV Community

Susan Potter
Susan Potter

Posted on • Originally published at susanpotter.net on

Conjuring TypeScript's Magic with Mapped Types

Table of Contents

  1. TypeScript: The Savvy Sidekick of JavaScript
  2. Why Mapped Types Matter
  3. Mapped Types Syntax and Basic Plumbing
    1. The Enchanting Syntax: Casting the Spell
    2. The Components of Mapped Types: Unmasking the Wizards
  4. A Deeper Dive!
    1. Modifying Property Modifiers: Bending the Rules of Nature
    2. Transforming Property Types: A Symphony of Type Transformations
  5. Tips, Tricks, and Best Practices: Unleashing the Magic Safely
    1. Tips for Working with Mapped Types: Navigating the Magical Realm
    2. Best Practices and Guidelines: Taming the Magic for Large-Scale Projects
  6. Wrapping it up

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!

TypeScript: The Savvy Sidekick of JavaScript

Buckle up, folks! TypeScript swoops in like a suave sidekick, adding an extra layer of pizzazz to the world of JavaScript. With TypeScript, you get to catch those pesky bugs before they even think about causing chaos in your code.

It’s time to introduce you to the heroes of our story: mapped types! These 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: The Plot Thickens

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;
    };
Enter fullscreen mode Exit fullscreen mode

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'>;
Enter fullscreen mode Exit fullscreen mode

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

    {
      name: string;
      age: number;
    }
Enter fullscreen mode Exit fullscreen mode

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

Mapped Types Syntax and Basic Plumbing

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

The Enchanting Syntax: Casting the Spell

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
    };
Enter fullscreen mode Exit fullscreen mode

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: Unmasking the Wizards

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]
    };
Enter fullscreen mode Exit fullscreen mode

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;
    };
Enter fullscreen mode Exit fullscreen mode

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
    };
Enter fullscreen mode Exit fullscreen mode

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
    };
Enter fullscreen mode Exit fullscreen mode

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;
    };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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,
    };
Enter fullscreen mode Exit fullscreen mode

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]
    };
Enter fullscreen mode Exit fullscreen mode

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;
    };
Enter fullscreen mode Exit fullscreen mode

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 };
    }
Enter fullscreen mode Exit fullscreen mode

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: Bending the Rules of Nature

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]
    };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
    };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: A Symphony of Type Transformations

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" };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)