Did you know that TypeScript allows you to map over a type like you would with an array?
Mapped types makes this possible and unlocks huge potential for transforming your types in a way that reduces code duplication while also enhancing the maintainability of your programs.
In this guide I’ll walk you through exactly how mapped types can makes your code more flexible and maintainable.
So grab a coffee and a notepad, and let’s dive in!
Sidenote: If you're struggling to understand mapped types and how to use them, want to learn more, or simply just want a deeper dive into TypeScript, then check out my complete course on Typescript here.
It takes you from a complete beginner to building real-world applications and being able to get hired, so there's something for every level of TypeScript user.
Check it out above, or watch the first videos here for free.
With that out of the way, let's get into this guide...
Mapped types are built using TypeScript's type system and they allow you to create new types based on existing ones. They work by 'mapping' properties of one type to another type.
It's a lot like iterating over an array of objects, but we are iterating over types instead. Since it's also a transformative operation, we can modify characteristics of the types, such as marking it readonly
or changing it to something else entirely.
Mapped types operate on all properties of a given type, so they offer a convenient way to create types without having to duplicate every property.
For example
Here we can create a mapped type that has the same properties and types of the original:
// Here's our existing type
type OriginalType = {
propertyA: string;
propertyB: number;
};
// And here's our mapped type
type MappedType = {
[ K in keyof OriginalType ]: OriginalType[K];
// | (A) |______(B)_______| | |_____(D)_____|
// | |
// |____________(C)____________|
};
So what's happening?
In the code above, MappedType
will have the same properties as OriginalType
. The syntax inside the square brackets is key to how mapped types work, but it can be bit daunting at first so let's break it apart:
K in
: This will create a new key (or property) from a collection of provided properties. The K
is generic and any letter may be usedkeyof OriginalType
: The keyof
keyword will provide us with all keys (or properties) of OriginalType
. In this case, it will give us propertyA
and propertyB
[K in keyof OriginalType]
: Enclosing the entire statement with square braces will iterate over properties in OriginalType
(so, propertyA
and propertyB
) and assign them to K
At this point we can imagine that we have a code fragment that looks something like this:
type ImaginaryType = {
propertyA
propertyB
}
However, we are still missing the types for each property. Those are filled in using the types from OriginalType
by accessing them using K
on part (D): : OriginalType[K]
.
This gives us a complete mapped type with properties and keys:
type ImaginaryType = {
propertyA: string;
propertyB: number;
}
Note that
ImaginaryType
doesn't actually exist. It is used only to illustrate what the final type will look like had you manually written it. TheImaginaryType
will be generated automatically on-demand (like when you hover over the type in your IDE) and is used exclusively for type checking.
You might be wondering why you should even care about mapped types. After all, isn't TypeScript just about adding static types to JavaScript? Why do we need to modify types and create new ones based on existing types?
Here's a few reasons why you might want to use mapped types:
Reducing code duplication: Instead of redefining the same set of properties for multiple types, mapped types allow you to define the properties once and then create new types based on the original one. This significantly reduces the amount of boilerplate code while enhancing code maintainability
Making code more flexible: Mapped types allow you to modify types on-the-fly. Want to make all properties of a type optional? Or readonly
? Mapped types have you covered
Enhancing type safety: By creating new types based on existing ones, you ensure that any changes to the original type are automatically reflected in the mapped type. This results in a more maintainable codebase where type changes are propagated throughout the application
Let's go over an example showing how mapped types can be used to reduce code duplication while also making the program more reliable.
Imagine a form submission system with 3 steps:
During step 2 (review), we don't want the user to edit any information. This is strictly for them to confirm that the previously entered data was correct.
We can create a mapped typed that doesn't allow editing of the form data like so:
// This is our initial user-editable type
type FormData = {
name: string;
age: number;
};
// We create a mapped type with all the properties from the form
type ReadonlyFormData = {
// We add the readonly keyword so the properties cannot be edited
readonly [K in keyof FormData]: FormData[K];
};
On the step 1 (data entry) page we can use FormData
and the user will be able to fill out the form and make edits.
However, once they go on to step 2 (review), we can switch over to the ReadonlyFormData
which adds the readonly
keyword to the properties. This prevents us from writing any code that would allow the user to make edits to the data.
Using mapped types in this way make your code safer, more flexible, and more maintainable. That's a win in any developer's book.
Mapped types get even better when we combine them with other TypeScript features.
It's time to talk about two important concepts in TypeScript - indexed access types and index signatures. Understanding these is essential to fully grasp the power of mapped types.
First, let's talk about indexed access types.
Indexed access types enable you to reference the type of a property in an object. The syntax is similar to how you would access the value of a property within an object in JavaScript: foo['property']
.
For example
type Person = {
name: string;
age: number;
};
// Indexed Access Types
type PersonName = Person['name']; // string
type PersonAge = Person['age']; // number
In this example, PersonName
is of type string
, and PersonAge
is of type number
.
We're able to access the types of the properties name
and age
on Person
using the indexed access operator ([]
). Now we can use them like we would any other type, and any changes to Person
will be reflected elsewhere:
type Student = {
name: PersonName, // string
age: PersonName, // number
}
Next, we have index signatures.
An index signature allows you to define the types of "indexable" properties, i.e., the properties that could be accessed using a numeric or string index.
For example
type Receipt = {
[index: string]: number;
};
In this case, Receipt
can have any number of properties with string
names, and the type for each of those properties must be a number
.
This is useful when you need to enforce specific types in an object, like so:
const groceries: Receipt = {
apples: 3.30,
bread: 1.20,
juice: "2.15", // ERROR: string not assignable to number
};
Combining indexed access types and index signatures with mapped types can lead to some powerful and flexible type definitions, capable of handling complex problems.
TypeScript comes with utility types that provide convenient type transformations and can be utilized in various scenarios, supplementing nicely with mapped types.
Remember, the beauty of mapped types lies in their ability to create new types based on existing ones, and utility types enhances this by offering commonly used transformations out-of-the-box.
One of the most common utility types used in conjunction with mapped types is Partial<T>
. What Partial<T>
does is essentially create a new type with all the properties of the input type T
made optional.
So, if you think about it, Partial<T>
is actually a mapped type!
Here's an example of Partial<T>
in action:
type Person = {
name: string;
age: number;
};
type PartialPerson = Partial<Person>;
As you can see, PartialPerson
is a new type where name
and age
properties are optional.
But what if you wanted to make all properties of a type read-only?
Well, there's a utility type for that too! It's called Readonly<T>
, and it looks like this:
type Person = {
name: string;
age: number;
};
type ImmutablePerson = Readonly<Person>;
ImmutablePerson
is now a type with the same properties as Person
, but you can't change them because they're marked readonly
.
There are several other utility types available, such as Record<K,T>
, Pick<T,K>
, Omit<T,K>
and more, each serving a unique purpose.
And there we have it - an introductory guide to understanding and working with mapped types in TypeScript.
But remember, reading is just the first step. The real mastery comes from practice!
So why not try experimenting with mapped types in your next project?
You could:
However, do keep in mind - there's no one-size-fits-all solution. Mapped types are just another tool in your TypeScript toolbox, and like all tools, they're most effective when used appropriately. Don't be afraid to experiment and adjust to find the solution that fits your project best.
If you want to learn more, check out my complete TypeScript course here on Zero To Mastery, or watch the first videos for free.
Like I said earlier, it can take you from a total beginner with basic JS experience, to building large scale apps and being able to be hired as a TypeScript developer.
You'll not only get the course content, you'll also get direct access to me, as well as other devs who are all learning TS, in our private Discord server!
You can ask questions, get feedback, or simply chat shop with other working developers and students!
If you've made it this far, you're clearly interested in TypeScript so definitely check out all of my other TypeScript posts and content: