Unlocking TypeScript's Hidden Gem: Mastering Mapped Types

Unlocking TypeScript's Hidden Gem: Mastering Mapped Types

In this article, we will explore one of TypeScript's most powerful features: mapped types. By the end of this lesson, you'll have a solid understanding of what mapped types are, their advantages, and how to use them effectively in your TypeScript projects.

What is a Mapped Type?

A mapped type enables the creation of a new type based on an existing one. The term "map" signifies the process of assigning existing properties to a new type using custom logic specific to the mapper's implementation.

For example, you can take an existing interface and retain all its properties while making them optional or read-only.

Advantages of Mapped Types

First Advantage:

Mapped types simplify TypeScript by removing the need to create additional interfaces for each transformation. Previously, every new type required its own interface, cluttering the codebase and making maintenance difficult as changes to the original interface required updates to all related interfaces.

For example, before mapped types, creating read-only and writable versions of an interface required duplicating the interface:

interface OriginalInterface {
    x: number;
    y: string;
}

interface ReadOnlyOriginalInterface {
    readonly x: number;
    readonly y: string;
}

Second Advantage:

Mapped types eliminate the need for separate functions to transform each interface. With mapped types and generics, a single transformation function can handle various types, making your code more efficient and easier to maintain.

// The interface that defines a structure that allows re-assignment of values 
interface OriginalInterface {
    x: number;
    y: string;
}

// The interface that defines a structure that allows assignment only at creation
interface ReadOnlyOriginalInterface {
    readonly x: number;
    readonly y: string;
}

Immutable Data with Readonly

Let's explore the readonly mapped type:

The Object.freeze Function

The Object.freeze function in JavaScript converts all properties of an object to read-only. TypeScript supports this with the Readonly<T> mapped type, which takes an interface T and makes all its properties read-only. The term "mapped" refers to using the in keyword to loop through and transform each property.

interface OriginalInterface {
    x: number;
    y: string;
}

type ReadonlyInterface<T> = { readonly [P in keyof T]: T[P] };

Adding Generics with Mapped Types

Here’s a simple example of using generics with a mapped type to create a read-only version of an interface:

interface OriginalInterface {
    x: number;
    y: string;
}

type ReadonlyInterface<T> = { readonly [P in keyof T]: T[P] };

The ReadonlyInterface<T> mapped type iterates over all properties of T and makes them read-only. You don’t need to define this manually, as TypeScript provides the built-in Readonly mapped type, which works the same way.

Partial

Let's explain the Partial mapped type.

The Partial Mapped Type

The Partial mapped type makes all properties of an interface optional. This is useful when you need to create a version of a type where properties can be optional, avoiding the need to duplicate interfaces.

type Partial<T> = {
    [P in keyof T]?: T[P];
}

For example, consider the following type:

interface OriginalInterface {
    x: number;
    y: string;
}

Using Partial, you can create a version where all properties are optional:

type PartialOriginalInterface = Partial<OriginalInterface>;

This avoids duplicating interfaces and ensures maintainability, as you don't need to keep multiple interfaces in sync. The Partial mapped type adds a question mark to each property, making them optional.

In scenarios where only some properties of an object might be updated, Partial is ideal. For instance, when a user edits a form and only changes a few fields, you can use a Partial type to handle the partial updates.

Nullable

This section explains the Nullable mapped type.

Nullable Mapped Type

A mapped type can handle null similarly to Partial<T>. You can create your own Nullable<T> type.

First, create a specific nullable type for an entity like Cat:

interface Cat {
  age: number;
  weight: number;
  numberOfKitty: number;
}

type NullableCat = { [P in keyof Cat]: Cat[P] | null };

This adds null to all fields of the Cat type.

Generic Nullable Mapped Type

To avoid duplicating this logic for every type, use a generic parameter:

type Nullable<T> = { [P in keyof T]: T[P] | null };

This makes any type nullable without duplicating fields, ensuring maintainability across different entities like Cat or Dog.

Here's the complete example for clarity:

interface Cat {
  age: number;
  weight: number;
  numberOfKitty: number;
}

const cat1: Cat = { age: 1, weight: 2, numberOfKitty: 0 };

type Nullable<T> = { [P in keyof T]: T[P] | null };

Pick

This section explains the Pick mapped type.

Description of Pick

The Pick mapped type allows you to create a dynamic type by selecting a subset of a type’s properties.

Inheritance Issues

Without Pick, you either duplicate interfaces or use inheritance, which can lead to redundancy and maintenance challenges.

Improvement with Pick

Pick offers a cleaner solution by allowing you to create a new type from specific properties of an existing type.

interface Animal {
  age: number;
  numberOfLegs: number;
  canSwim: boolean;
  runningSpeed: number;
  name: string;
}

type PickedAnimal = Pick<Animal, 'age' | 'name'>;

Dynamic Type Creation with Pick

Pick is useful for dynamically creating types from existing ones, avoiding the need for multiple interfaces and simplifying code maintenance.

Using Pick with keyof

Pick uses keyof to ensure only valid properties are selected, making the code type-safe.

type PickedAnimal = Pick<Animal, 'age' | 'name'>;

This method avoids creating unnecessary interfaces and makes the code more maintainable.

Omit

Let's explain the Omit mapped type.

Overview

The Omit mapped type is the inverse of Pick. While Pick selects specific members of a type, Omit excludes specified members.

Example

Here's an example using Omit:

interface Animal {
  age: number;
  name: string;
  maximumDeepness: number;
  numberOfLegs: number;
  canSwim: boolean;
  runningSpeed: number;
}

type PartialAnimal = Omit<Animal, 'maximumDeepness' | 'runningSpeed'>;

In this code, PartialAnimal includes all Animal properties except maximumDeepness and runningSpeed.

How Omit Works

Omit combines Pick and Exclude:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

This type first excludes the specified keys (K) and then picks the remaining keys from the type (T).

Record

This lesson explains the Record mapped type.

Overview

The Record type is one of TypeScript’s default mapped types. It allows you to create a type with specific keys and a single type for all values.

Example

Here's an example using Record:

type RecordType1 = Record<"m1" | "m2" | "m3", string>;
const x: RecordType1 = { m1: "s1", m2: "s2", m3: "s3" };
console.log(x);

In this code, RecordType1 defines three string fields: m1, m2, and m3.

When to Use Record

Use Record when you need to convert user input data into a strongly-typed object. For example, if user input from a web form needs to be processed into specific types like numbers or booleans.

How It Works

Record takes two parameters: the keys and the type of the values. For example:

dataIn: Record<keyof Animal, string>

is equivalent to:

dataIn: Record<"age" | "name" | "maximumDeepness" | "numberOfLegs" | "canSwim" | "runningSpeed", string>

This ensures type safety and reduces the risk of typos.

Extract

This section explains the Extract mapped type.

Overview

The Extract mapped type allows us to extract from a set of types those that are common in another type.

Example

Here's an example of using Extract:

type OnlyArrayType = Extract<string | string[] | number[], unknown[]>;

const var1: OnlyArrayType = ["Element1"];
const var2: OnlyArrayType = [1];

In this code, OnlyArrayType returns string[] or number[], as they are the only types that fulfill the conditions of an array.

Advantage

Extract is useful for identifying common members between different types. For example:

type LivingThing = Extract<keyof Animal, keyof Human>;

This identifies the common member name between the Animal and Human interfaces.

Exclude

This section introduces the exclude mapped type.

Overview

Exclude works similarly to Extract, but instead of selecting properties, it removes the specified one from a type.

Example

Consider this code:

type LivingThing = Exclude<keyof Animal, "sound">;

In this code, LivingThing is a new type that contains all properties from Animal except for "sound".

Usage

Exclude is handy when you need to create types by removing specific properties. For example, you can create a type HumanWithoutNickname by excluding the "nickname" property from Human.

type HumanWithoutNickname = Exclude<keyof Human, "nickname">;

ReturnType

This lesson explores the ReturnType mapped type.

Overview

ReturnType is used to extract the return type of a function in TypeScript.

Example

Consider this code:

function getName(): string {
    return "Name";
}
type FunctionType = ReturnType<typeof getName>;
const varX: FunctionType = "This is a string";
console.log(varX);

In this example, FunctionType is inferred as string because the return type of the function getName is string.

Importance

ReturnType is crucial for maintaining type safety. If the return type of a function changes, TypeScript automatically updates the inferred type accordingly, ensuring code integrity.

Handling Multiple Return Types

ReturnType can handle functions with multiple return types. Even if the function returns different types based on conditions, TypeScript infers the correct type.

Handling Asynchronous Functions

For asynchronous functions, ReturnType returns a Promise of the function's return type. However, you can extract the inner type using conditional types, ensuring precise type extraction.

Custom Mapped Type

This section guides you through creating your own mapped types.

Creating a "NonNullable" Type

The NoNullValue type ensures that a variable is neither null nor undefined. If the value is either one, TypeScript won't compile.

type NoNullValue<T> = T extends null | undefined ? never : T;

function print<T>(p: NoNullValue<T>): void {
    console.log(p);  
}

print("Test"); // Compiles
// print(null); // Doesn't compile

Adding a Property Conditionally

Consider a scenario where you want to automatically add a modifiedDate property to an object if it already has a dateCreated property.

interface Person {
    name: string;
    dateCreated: Date;
}
interface Animal {
    name: string;
}

// Add modifiedDate only if dateCreated is present
type Modified<T> = T extends { dateCreated: Date } ? T & { modifiedDate: Date } : never;

In this example, Modified<T> compiles only if the generic type T includes a dateCreated property. Otherwise, it returns never, prompting TypeScript not to compile. This approach ensures type safety by enforcing the presence of specific properties conditionally.

Conclusion

In conclusion, understanding and creating custom mapped types in TypeScript opens up a world of possibilities for ensuring type safety and enhancing code clarity.

By creating types like NoNullValue and Modified, we can enforce constraints on our data structures, ensuring that our code behaves as expected and preventing potential runtime errors.

Whether it's ensuring that variables are non-null, adding properties conditionally, or other advanced type manipulations, custom mapped types empower developers to write cleaner, safer, and more maintainable code in TypeScript projects.

Mastering these techniques not only improves the robustness of our applications but also enhances our productivity as developers by catching errors early in the development process. So, dive in, explore, and leverage the full potential of custom mapped types in TypeScript!