typescript
javascript
mapped-types
type-definitions
type-safety

Mapped Types in TypeScript

Mapped types are incredibly useful when you want to write highly reusable types that reduce boilerplate or when you're crafting developer-friendly, type-safe APIs.

TypeScript includes a wealth of built-in mapped types. Whenever you use Partial, Required, or similar constructs, a mapped type is working behind the scenes.

Additionally, you might sometimes need to execute some logic in TypeScript, such as removing properties from an object, transforming them to another type (number -> string), adding more advanced operations like assigning keys of a generic type, and much, much more...

Let's explore this concept, examine examples, implement custom mapped types, use them in real logic, and navigate through the extensive list of built-in ones.

Understanding Type Operations

When you observe a regular JavaScript function that removes keys from an object, you can clearly understand what's happening.

function removeKeysOfType(obj, type) {
    const result = {};
    for (const key in obj) {
        if (typeof obj[key] !== type) {
            result[key] = obj[key];
        }
    }
    return result;
}

// Example usage:
const originalObject = {
    name: "Alice",
    age: 25,
    hasPets: true,
    pets: ["cat", "dog"]
};

// Remove all string properties
const filteredObject = removeKeysOfType(originalObject, 'string');
console.log(filteredObject);

// Expected console output:
// { age: 25, hasPets: true, pets: ["cat", "dog"] }

Why do you see that? It's because the same principles can be applied to types. We can pass any type, create a mapped type that takes a generic argument, and as a result, produce a type without the specified ones.

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

// Example usage:
type Example = {
    name: string;
    age: number;
    hasPets: boolean;
    pets: string[];
};

type FilteredExample = RemoveKeysOfType<Example, string | number>;

// The resulting type FilteredExample will be:
// {
//   hasPets: boolean;
//   pets: string[];
// }

Let's understand what happened here:

  • T is the type of the object from which keys will be removed.
  • U is the type that should be removed from the object.
  • [P in keyof T as T[P] extends U ? never : P] iterates over each property P of type T. It checks if the type of P (T[P]) extends type U. If it does, it maps P to never, effectively removing it from the resulting type. Otherwise, it retains P.

The never is used to remove a property, it's like the delete keyword in JavaScript.

The point is, that our RemoveKeysOfType works similarly to the JavaScript function, but instead of taking an actual memory-allocated object, we're passing an object type (contract).

Little definition: Mapped types perform operations on a given type and transform it into another.

Now, you see the direction. Let's practice mapped types to master them.

Practicing Mapped Types

Let's say we want a type that takes an object shape and creates a type with the object's keys. Each key will have a value that is a function, a common pattern for creating form management logic mechanisms. Here's what it looks like:

// Baseline type for validation - only objects may be passed
type ValuesBase = Record<string | number, any>;
// Validator function signature
type ValidatorFn<Value> = (value: Value) => boolean;

// Mapped type that contains functions as values with assigned keys.
// The "?" symbol makes properties optional.
type ValidatorsSetup<Values extends ValuesBase> = {
  [Key in keyof Values]?: ValidatorFn<Values[Key]>[];
};

We can utilize it as follows:

// Application-specific type
type UserFormData = {
  username: string;
  password: string;
};

const userFormValidators: ValidatorsSetup<UserFormData> = {
  username: [(value) => value.length > 0, (value) => value.length < 20],
};

What's interesting? We don't need to pass all keys to create validators; we can include just a part of them or none at all. Additionally, the value in callbacks is already typed as string because it reads the value type from UserFormData and assigns it to the callback function.

Finally, when specifying the configuration for our validation setup, we have hints about which properties we may use - those specified in the application-specific type UserFormData. Check the following gif to understand how it works during development:

How Mapped Types Work in IDE Mapped Types in IDE

In summary, our mapped type ValidatorsSetup takes a given object type, reads the type of each key, and assigns it as the parameter type for the function callback. This facilitates strongly typed function definitions based on the properties of the input object type.

Builded-In Mapped Types

TypeScript offers a number of built-in mapped types that are highly useful for transforming types in a flexible and reusable manner. Here are several of the most commonly used:

interface User {
  name: string;
  age: number;
  email: string;
}

// Partial<T>
type PartialUser = Partial<User>;
// Shape: { name?: string; age?: number; email?: string; }

// Required<T>
type RequiredUser = Required<User>;
// Shape: { name: string; age: number; email: string; }

// Readonly<T>
type ReadonlyUser = Readonly<User>;
// Shape: { readonly name: string; readonly age: number; readonly email: string; }

// Record<K, T>
type RoleAccess = Record<string, number>;
// Shape: { [key: string]: number; }

// Pick<T, K>
type UserContactDetails = Pick<User, "name" | "email">;
// Shape: { name: string; email: string; }

// Omit<T, K>
type UserPersonalDetails = Omit<User, "email">;
// Shape: { name: string; age: number; }

// Exclude<T, U>
type PrimaryColors = Exclude<"red" | "blue" | "green" | "yellow", "green" | "yellow">;
// Shape: "red" | "blue"

// Extract<T, U>
type NonPrimaryColors = Extract<"red" | "blue" | "green" | "yellow", "green" | "yellow">;
// Shape: "green" | "yellow"

// NonNullable<T>
type NonNullableString = NonNullable<string | null | undefined>;
// Shape: string

If you're interested in all of them, here you've documentation page.

Summary

Mapped types in TypeScript are versatile tools for creating new types based on existing ones by programmatically transforming their properties. Here's a concise overview:

Purpose: Mapped types allow for property transformations, such as making them optional, required, or read-only.

Syntax: They use a special syntax [Key in Keys] where Keys is typically a union of property names.

Common Uses:

  • Partial<T>: Makes all properties optional.
  • Required<T>: Makes all properties required.
  • Readonly<T>: Makes all properties read-only.
  • Pick<T, K>: Picks certain properties to create a new type.
  • Omit<T, K>: Omits specified properties from a type.
  • Record<K, T>: Creates a type with property keys K and type T.

Utility: Mapped types are useful for modifying types in a type-safe manner, enhancing flexibility and maintainability in large-scale applications.

Others: Mapped types are often used with the extends keyword (type constraints) to add basic validation for the passed generic types. Additionally, to perform some filtering, we may use ternary types - [P in keyof T as T[P] extends U ? never : P]: T[P].

Mapped types thus play a crucial role in utilizing TypeScript's type system to its full potential, enabling developers to write cleaner, more efficient code.