Having Fun with TypeScript: Mapped Types

Dagang Wei
4 min readApr 8, 2024

--

This blog post is part of the series Having Fun with TypeScript.

Introduction

TypeScript’s structural type system gives us a tremendous amount of flexibility to model data. One of the powerful tools it provides is mapped types, which allow us to create new types by transforming properties of existing types. Let’s delve into the world of mapped types!

What are Mapped Types?

In essence, mapped types act like a blueprint for constructing a new type. They iterate over the properties of an existing type and apply a transformation to each property, generating a new type as the output. The core syntax of a mapped type looks like this:

type NewType = {
[Property in keyof OriginalType]: NewPropertyType
}

Let’s break this down:

  • NewType: The name of the type you're creating.
  • Property: An iterator variable that will hold the keys (string) of the OriginalType.
  • keyof OriginalType: The keyof operator extracts the property names of the OriginalType as a union of strings.
  • NewPropertyType: The transformed type of each property.

Why Use Mapped Types?

Mapped types shine in various scenarios:

  • Refactoring and Code Maintainability: When you need to change a property’s type across an object, mapped types streamline the process. They establish a clear link between the original type and the transformed one, ensuring updates are propagated correctly.
  • Creating Variations: Mapped types are excellent for generating variations on an existing type. For example, making all properties optional, or turning them into readonly versions.
  • Enforcing Constraints: You can use mapped types to add constraints to object types, such as ensuring specific properties are always present.

Examples in Action

Let’s look at some practical use cases:

1. Making Properties Optional

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

type PartialPerson = {
[Property in keyof Person]?: Person[Property];
}

const partialPerson: PartialPerson = { name: 'John' };

Here, we’ve created a PartialPerson type where all properties from Person are marked as optional.

2. Making Properties Readonly

interface Todo {
title: string;
completed: boolean;
}

type ReadonlyTodo = {
readonly [Property in keyof Todo]: Todo[Property];
}

The ReadonlyTodo type enforces that none of the properties in a Todo object can be modified.

3. Transforming Property Types

interface Options {
debug: boolean;
port: number;
}

type StringifiedOptions = {
[Property in keyof Options]: string;
}

We’ve taken a Options interface and transformed all its property types to strings, creating a StringifiedOptions type.

Advanced Use Cases

1. Conditional Transformations

Conditional types allow you to apply different transformations based on the type of a property. Here’s an example where we want to mark all non-function properties of an object as readonly:

type ReadonlyNonFunctions<T> = {
[Property in keyof T]: T[Property] extends Function ? T[Property] : readonly T[Property];
}

interface MutableObject {
count: number;
updateCount(): void;
}

type ReadonlyMutableObject = ReadonlyNonFunctions<MutableObject>;

In the ReadonlyNonFunctions type, we are conditionally checking if the property type (T[Property]) extends a Function. If it does, the type remains unchanged, but otherwise, it's marked as readonly.

2. Filtering Properties

You can filter which properties are included in your mapped type based on certain conditions. Consider creating a type that only has numeric properties:

interface MixedProperties {
name: string;
count: number;
flag: boolean;
age: number;
}

type FilterPropertiesByType<T, ValueType> = {
[Property in keyof T as T[Property] extends ValueType ? Property : never]: T[Property]
};

type NumberPropertiesObject = FilterPropertiesByType<MixedProperties, number>;
const obj: NumberPropertiesObject = { count: 1, age: 15 };

We use a type predicate to include properties only if T[Property] extends the number type. The never keyword allows us to exclude the properties that don't match the condition.

3. Key Remapping

Sometimes, you might want to rename keys while creating a new type. Here’s how you can use mapped types for key remapping:

type RenameKeys<T, Mapping extends Record<string, any>> = {
[OldKey in keyof T as OldKey extends keyof Mapping ? Mapping[OldKey] : OldKey]: T[OldKey]
};

interface Person {
firstName: string;
lastName: string;
}

type RenamedPerson = RenameKeys<Person, { firstName: "first", lastName: "last"} >;

// RenamedPerson is equivalent to:
// {
// first: string;
// last: string;
// }

const renamedPerson: RenamedPerson = { first: "John", last: "Smith" };

In this case, the Mapping type defines how the original keys will be renamed.

Note: The above examples assume you have a solid understanding of conditional types and how they work.

Let’s Recap

These advanced use cases demonstrate how mapped types, combined with other TypeScript features, offer powerful ways to:

  • Create tailored type transformations: Apply specific logic based on property types.
  • Selective property manipulation: Control which properties are included or altered.
  • Enhance type flexibility: Reshape a type’s structure as needed.

In Summary

Mapped types are a valuable addition to your TypeScript toolbox. They help you write more expressive, maintainable, and robust code. If you’re looking to take your TypeScript skills to the next level, mastering mapped types will serve you well!

--

--