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!