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 whichkeys
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 propertyP
of typeT
. It checks if the type ofP
(T[P]
) extends typeU
. If it does, it mapsP
tonever
, effectively removing it from the resulting type. Otherwise, it retainsP
.
The
never
is used to remove a property, it's like thedelete
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:
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 keysK
and typeT
.
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.