TypeScript's mapped types offer a powerful mechanism for creating new types by manipulating the properties of existing types. This chapter delves into the world of mapped types, exploring their fundamentals, advanced usages, and best practices for effectively transforming object structures in your code.
Imagine you have an interface or type defining an object structure. Mapped types allow you to create a new type based on the original one, transforming its properties in a specific way.
interface User {
name: string;
age: number;
}
// ReadonlyUser makes all properties readonly based on User
type ReadonlyUser = Readonly;
const user: User = { name: "Alice", age: 30 };
const readonlyUser: ReadonlyUser = { name: "Bob", age: 35 };
// user.name = "Charlie"; // Valid: name is mutable in User
// readonlyUser.name = "David"; // Error: name is readonly in ReadonlyUser
The core concept of mapped types lies in transforming the types of properties within the new type. Here, Readonly
is used to make all properties readonly. You can define custom mappings for specific needs.
interface Product {
name: string;
price: number;
}
// OptionalProduct makes all properties optional based on Product
type OptionalProduct = Partial;
const product: Product = { name: "Shirt", price: 20.5 };
const optionalProduct: OptionalProduct = { name: "Hat" }; // Only name provided, valid
// OptionalProduct allows omitting properties without errors
Mapped types allow you to not only transform property types but also rename them during the creation of the new type.
interface User {
name: string;
age: number;
}
// UserDetails renames name to fullName and adds email property
type UserDetails = {
fullName: User["name"]; // Accessing property type from User
email: string;
};
const userDetails: UserDetails = {
fullName: "Charlie Brown",
email: "charlie@example.com"
};
You can combine renaming with type transformations for more complex manipulations.
interface Product {
name: string;
price: number;
}
// DiscountedProduct renames price to salePrice (string) with a discount applied
type DiscountedProduct = {
name: Product["name"];
salePrice: string; // New property type (string)
};
function getDiscountedPrice(product: Product, discount: number): string {
return (product.price * (1 - discount)).toFixed(2);
}
const product: Product = { name: "Shirt", price: 20.5 };
const discountedProduct: DiscountedProduct = {
name: product.name,
salePrice: getDiscountedPrice(product, 0.1) // Applying discount
};
console.log(discountedProduct.salePrice); // Output: "18.45"
Mapped types can leverage conditional logic to create new types based on the properties or values within the original type.
interface User {
name: string;
role: "admin" | "editor" | "user";
}
// Permissions type with conditional property based on role
type Permissions = {
[key: string]: boolean;
canEdit?: boolean; // Optional for non-admin roles
canDelete?: boolean; // Only for admin role
} & { [P in keyof User]: User[P] }; // Keep original properties
const adminUser: User & Permissions = {
name: "Alice",
role: "admin",
canEdit: true,
canDelete: true
};
const editorUser: User & Permissions = {
name: "Bob",
role: "editor",
canEdit: true
};
// Error: canDelete is not allowed for non-admin roles
// const nonAdminUser: User & Permissions = {
// name: "Charlie",
// role: "user",
// canDelete: false
// };
Conditional mapped types add another layer of type safety by ensuring properties only exist or have specific types based on conditions within the original type. This helps prevent runtime errors and improves code reliability.
Mapped types can be combined with existing interfaces to create new types that inherit properties from both.
interface Product {
name: string;
price: number;
}
// DiscountedProduct with price discount and readonly properties
type DiscountedProduct = Readonly;
const product: Product = { name: "Hat", price: 15 };
const discountedProduct: DiscountedProduct = {
name: product.name, // Inheriting from Product
price: product.price, // Inheriting from Product (readonly)
salePrice: "13.50" // New property
};
// discountedProduct.price = 10; // Error: price is readonly
This approach allows you to define a base structure with an interface and then create variations using mapped types for specific scenarios like discounts or read-only access.
TypeScript provides built-in utility types like Partial
, Pick
, and Record
that can be used within mapped types for common transformations.
interface User {
name: string;
age: number;
email: string;
}
// UserSummary picks only name and email properties
type UserSummary = Pick;
const user: User = { name: "David", age: 32, email: "david@example.com" };
const userSummary: UserSummary = { name: user.name, email: user.email };
// LoginCredentials picks only email for login purpose (using Record)
type LoginCredentials = Record<"email", string>;
const loginCredentials: LoginCredentials = { email: user.email };
Utilizing existing utility types within mapped types improves code readability and maintainability by leveraging established functionality for common transformations.
Mapped types are ideal for scenarios where you need to create new types based on existing ones, modifying property types, renaming properties, or applying conditional logic for property existence.
By defining generic mapped types, you can create reusable transformations that can be applied to various object structures, promoting code reusability and maintainability.
Mapped types provide a versatile tool for manipulating object structures and creating new types in TypeScript. By understanding their capabilities, limitations, and best practices, you can leverage them effectively to write cleaner, more maintainable, and type-safe code for various scenarios. Happy coding !❤️