Having Fun with TypeScript: Mapped Types
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 theOriginalType
.keyof OriginalType
: Thekeyof
operator extracts the property names of theOriginalType
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!