Bài 14.2: Mapped types trong TypeScript

Bài 14.2: Mapped types trong TypeScript
Trong thế giới TypeScript đầy năng động, việc quản lý và biến đổi các cấu trúc dữ liệu (types) là vô cùng quan trọng. Đôi khi, chúng ta cần tạo ra một kiểu dữ liệu mới dựa trên một kiểu dữ liệu hiện có, nhưng với một số thay đổi nhất định, ví dụ: làm cho tất cả các thuộc tính trở thành tùy chọn, hoặc chỉ lấy một số thuộc tính nhất định, hoặc thậm chí biến đổi kiểu của từng thuộc tính. Đây chính là lúc Mapped Types tỏa sáng!
Mapped Types cho phép chúng ta tạo ra các kiểu dữ liệu mới bằng cách lặp qua các thuộc tính (keys) của một kiểu dữ liệu khác và áp dụng một phép biến đổi (transformation) lên từng thuộc tính đó. Nghe có vẻ trừu tượng? Đừng lo, chúng ta sẽ đi vào chi tiết với rất nhiều ví dụ minh họa.
Về cơ bản, cú pháp của Mapped Types trông giống như một khai báo index signature (chữ ký chỉ mục), nhưng thay vì mô tả kiểu của một tập hợp các thuộc tính cố định (ví dụ: [key: string]: any
), nó mô tả cách tất cả các thuộc tính từ một kiểu khác sẽ được ánh xạ (mapped) sang kiểu mới.
Cú pháp cơ bản là:
type NewType<T> = {
[P in K]: U
}
Trong đó:
T
: Kiểu dữ liệu gốc mà chúng ta muốn biến đổi.K
: Thông thường sẽ làkeyof T
, tức là tập hợp tất cả các tên thuộc tính (dưới dạng literal string hoặc symbol) của kiểuT
.P
: Một biến lặp, đại diện cho từng tên thuộc tính trong tập hợpK
.in
: Từ khóa bắt buộc, chỉ định rằng chúng ta đang lặp qua các thuộc tính.U
: Kiểu dữ liệu mới mà thuộc tínhP
sẽ có trong kiểuNewType
.U
thường phụ thuộc vào kiểu gốc của thuộc tính đó trongT
, tức làT[P]
.
Hãy bắt đầu với một kiểu dữ liệu mẫu:
interface User {
id: number;
name: string;
age: number;
isActive: boolean;
}
Các Mapped Types Có Sẵn (Built-in Utility Types)
TypeScript cung cấp một số Mapped Types tiện ích có sẵn rất thông dụng. Chúng là những ví dụ tuyệt vời để hiểu cách hoạt động của Mapped Types.
1. Partial<T>
: Biến tất cả thuộc tính thành tùy chọn (Optional)
Mục đích: Tạo ra một kiểu dữ liệu mới dựa trên T
, trong đó tất cả các thuộc tính của T
đều trở thành tùy chọn (optional - có dấu ?
).
// Định nghĩa Partial<T> (một cách đơn giản)
type Partial<T> = {
[P in keyof T]+?: T[P]; // +? là cú pháp để thêm modifier optional
};
// Sử dụng Partial<User>
type PartialUser = Partial<User>;
/*
Kết quả của PartialUser sẽ là:
{
id?: number | undefined;
name?: string | undefined;
age?: number | undefined;
isActive?: boolean | undefined;
}
*/
// Ví dụ sử dụng:
const userUpdate: PartialUser = {
name: "Jane Doe" // Chỉ cần cung cấp thuộc tính muốn update
};
Giải thích: [P in keyof T]
lặp qua tất cả các keys (id
, name
, age
, isActive
) của User
. +?
thêm modifier optional cho mỗi property. T[P]
giữ nguyên kiểu dữ liệu gốc của thuộc tính đó (ví dụ: User['id']
là number
).
2. Required<T>
: Biến tất cả thuộc tính thành bắt buộc (Required)
Mục đích: Ngược lại với Partial
, Required<T>
đảm bảo tất cả các thuộc tính của T
đều là bắt buộc (non-optional). Hữu ích khi bạn nhận được một đối tượng có thể có các thuộc tính tùy chọn nhưng bạn cần xử lý nó như thể tất cả đều có mặt.
// Định nghĩa Required<T> (một cách đơn giản)
type Required<T> = {
[P in keyof T]-?: T[P]; // -? là cú pháp để loại bỏ modifier optional
};
// Giả sử có kiểu với thuộc tính tùy chọn
interface Config {
timeout?: number;
retries?: number;
url: string; // thuộc tính này đã bắt buộc
}
// Sử dụng Required<Config>
type FullConfig = Required<Config>;
/*
Kết quả của FullConfig sẽ là:
{
timeout: number; // đã bỏ optional
retries: number; // đã bỏ optional
url: string; // giữ nguyên bắt buộc
}
*/
// Ví dụ sử dụng:
const defaultConfig: FullConfig = {
timeout: 3000,
retries: 5,
url: "api.example.com"
};
// Lỗi nếu thiếu bất kỳ thuộc tính nào
// const invalidConfig: FullConfig = { url: "..." }; // <-- Lỗi compile
Giải thích: [P in keyof T]
lặp qua các keys của Config
. -?
loại bỏ modifier optional khỏi mỗi property (nếu có). T[P]
giữ nguyên kiểu dữ liệu gốc.
3. Readonly<T>
: Biến tất cả thuộc tính thành chỉ đọc (Readonly)
Mục đích: Tạo ra một kiểu dữ liệu mới dựa trên T
, trong đó tất cả các thuộc tính của T
đều không thể bị gán lại giá trị sau khi khởi tạo.
// Định nghĩa Readonly<T> (một cách đơn giản)
type Readonly<T> = {
readonly [P in keyof T]: T[P]; // readonly modifier
};
// Sử dụng Readonly<User>
type ReadonlyUser = Readonly<User>;
/*
Kết quả của ReadonlyUser sẽ là:
{
readonly id: number;
readonly name: string;
readonly age: number;
readonly isActive: boolean;
}
*/
// Ví dụ sử dụng:
const immutableUser: ReadonlyUser = {
id: 1,
name: "John Doe",
age: 30,
isActive: true
};
// immutableUser.age = 31; // <-- Lỗi compile: Cannot assign to 'age' because it is a read-only property.
Giải thích: [P in keyof T]
lặp qua keys. readonly
thêm modifier chỉ đọc cho mỗi property. T[P]
giữ nguyên kiểu.
4. Pick<T, K>
: Chọn một tập hợp con các thuộc tính
Mục đích: Tạo một kiểu mới bằng cách chỉ chọn những thuộc tính có tên nằm trong tập hợp K
(là một union type của các literal string) từ kiểu gốc T
.
// Định nghĩa Pick<T, K> (một cách đơn giản)
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Sử dụng Pick<User, 'name' | 'isActive'>
type UserSummary = Pick<User, 'name' | 'isActive'>;
/*
Kết quả của UserSummary sẽ là:
{
name: string;
isActive: boolean;
}
*/
// Ví dụ sử dụng:
const summary: UserSummary = {
name: "Alice",
isActive: false
};
// const invalidSummary: UserSummary = { name: "Bob", age: 25 }; // <-- Lỗi compile: Object literal may only specify known properties, and 'age' does not exist in type 'UserSummary'.
Giải thích: K extends keyof T
ràng buộc K
phải là một kiểu con của keyof T
, tức là các tên thuộc tính trong K
phải tồn tại trong T
. [P in K]
lặp qua chỉ các keys có trong K
(ví dụ: 'name'
và 'isActive'
). T[P]
lấy kiểu gốc của thuộc tính đó trong T
.
5. Omit<T, K>
: Loại bỏ một tập hợp con các thuộc tính
Mục đích: Tạo một kiểu mới bằng cách lấy tất cả các thuộc tính của T
trừ đi những thuộc tính có tên nằm trong tập hợp K
.
// Định nghĩa Omit<T, K> (một cách đơn giản)
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// (Sử dụng Pick và Exclude - Exclude<T, U> loại bỏ các thành viên của U khỏi T)
// Sử dụng Omit<User, 'id' | 'age'>
type UserWithoutSensitiveInfo = Omit<User, 'id' | 'age'>;
/*
Kết quả của UserWithoutSensitiveInfo sẽ là:
{
name: string;
isActive: boolean;
}
*/
// Ví dụ sử dụng:
const publicUserInfo: UserWithoutSensitiveInfo = {
name: "Charlie",
isActive: true
};
// const invalidUserInfo: UserWithoutSensitiveInfo = { id: 5, name: "David", isActive: false }; // <-- Lỗi compile
Giải thích: Việc triển khai Omit
thường dựa trên Pick
và kiểu tiện ích Exclude
. Exclude<keyof T, K>
tạo ra một union type chứa tất cả keys của T
ngoài các keys trong K
. Sau đó, Pick
được sử dụng để chọn chỉ các keys còn lại đó từ T
.
Tạo Custom Mapped Types
Sức mạnh thực sự của Mapped Types nằm ở khả năng tạo ra các phép biến đổi tùy chỉnh cho kiểu dữ liệu. Chúng ta có thể biến đổi không chỉ tính chất (optional, readonly) mà còn cả kiểu giá trị của các thuộc tính.
Ví dụ 1: Biến đổi tất cả các thuộc tính thành string
Hãy tạo một kiểu mới mà tất cả các thuộc tính của kiểu gốc đều có kiểu là string
.
type Stringified<T> = {
[P in keyof T]: string;
};
interface Product {
id: number;
name: string;
price: number;
isInStock: boolean;
}
type StringifiedProduct = Stringified<Product>;
/*
Kết quả của StringifiedProduct sẽ là:
{
id: string;
name: string;
price: string;
isInStock: string;
}
*/
// Ví dụ sử dụng:
const productDataAsString: StringifiedProduct = {
id: "123",
name: "Laptop",
price: "999.99",
isInStock: "true" // Lưu ý: Kiểu gốc là boolean nhưng ở đây thành string
};
Giải thích: [P in keyof T]
lặp qua các keys của Product
. string
chỉ định rằng giá trị của mỗi thuộc tính trong kiểu mới sẽ là string
, bất kể kiểu gốc của nó trong Product
là gì.
Ví dụ 2: Biến đổi tất cả các thuộc tính thành Promise
chứa kiểu gốc
Đôi khi, bạn có thể cần một phiên bản "bất đồng bộ" của một kiểu dữ liệu, nơi mỗi giá trị được wrap trong một Promise
.
type Promised<T> = {
[P in keyof T]: Promise<T[P]>;
};
interface Settings {
timeout: number;
apiUrl: string;
debugMode: boolean;
}
type PromisedSettings = Promised<Settings>;
/*
Kết quả của PromisedSettings sẽ là:
{
timeout: Promise<number>;
apiUrl: Promise<string>;
debugMode: Promise<boolean>;
}
*/
// Ví dụ sử dụng (giả định có các async function lấy dữ liệu)
async function loadSettings(): Promise<PromisedSettings> {
return {
timeout: Promise.resolve(5000),
apiUrl: Promise.resolve("/api/v1"),
debugMode: Promise.resolve(false)
};
}
Giải thích: [P in keyof T]
lặp qua các keys của Settings
. Promise<T[P]>
lấy kiểu gốc của thuộc tính đó trong Settings
(T[P]
) và wrap nó vào một Promise
.
Key Remapping thông qua as
Từ TypeScript 4.1, chúng ta có thể thay đổi tên của các thuộc tính trong quá trình ánh xạ bằng cách sử dụng từ khóa as
. Điều này rất mạnh mẽ khi kết hợp với Template Literal Types.
Cú pháp:
type NewType<T> = {
[P in keyof T as NewKeyType]: U
}
NewKeyType
là một biểu thức tính toán ra tên thuộc tính mới, thường dựa trên P
(tên thuộc tính gốc) và/hoặc T[P]
(kiểu thuộc tính gốc).
Ví dụ: Thêm tiền tố 'get' vào tên thuộc tính
Hãy tạo một kiểu dữ liệu mà mỗi thuộc tính có tên gốc propName
sẽ trở thành getPropName
, và giữ nguyên kiểu dữ liệu.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: T[K]
};
interface Data {
id: number;
userName: string;
isAuthenticated: boolean;
}
type DataGetters = Getters<Data>;
/*
Kết quả của DataGetters sẽ là:
{
getId: number;
getUserName: string;
getIsAuthenticated: boolean;
}
*/
// Ví dụ sử dụng (giả định có một object phù hợp với kiểu này)
const myDataGetters: DataGetters = {
getId: 101,
getUserName: "super_user",
getIsAuthenticated: true
};
// console.log(myDataGetters.getId); // 101
Giải thích:
[K in keyof T]
: Lặp qua các keys ('id'
,'userName'
,'isAuthenticated'
) củaData
.as \
get${Capitalize<string & K>}``: Đây là phần key remapping.`get${...}`
: Sử dụng Template Literal Type để tạo chuỗi mới bắt đầu bằngget
.Capitalize<...>
: Biến đổi ký tự đầu tiên thành chữ hoa.string & K
: Đảm bảo rằngK
(tên thuộc tính) được xử lý như mộtstring
trước khi áp dụngCapitalize
.
T[K]
: Giữ nguyên kiểu dữ liệu gốc của thuộc tính đó trongData
.
Lọc thuộc tính bằng Conditional Types
Chúng ta có thể kết hợp Mapped Types với Conditional Types để lọc bớt các thuộc tính dựa trên kiểu của chúng.
Cú pháp:
type FilteredType<T> = {
[P in keyof T as T[P] extends FilterCondition ? P : never]: T[P]
}
Nếu điều kiện T[P] extends FilterCondition
đúng, tên thuộc tính P
sẽ được giữ lại. Nếu sai, tên thuộc tính sẽ là never
. Khi một thuộc tính ánh xạ tới tên never
, nó sẽ bị loại bỏ khỏi kiểu kết quả.
Ví dụ: Chỉ lấy các thuộc tính có kiểu là string
type OnlyStrings<T> = {
[P in keyof T as T[P] extends string ? P : never]: T[P]
};
interface MixedData {
name: string;
age: number;
address: string;
isAdmin: boolean;
email?: string; // Optional string
}
type MixedDataStringsOnly = OnlyStrings<MixedData>;
/*
Kết quả của MixedDataStringsOnly sẽ là:
{
name: string;
address: string;
email?: string | undefined;
}
*/
// Ví dụ sử dụng:
const stringData: MixedDataStringsOnly = {
name: "Eve",
address: "123 Main St"
// email là optional, không cần có ở đây
};
const stringDataFull: MixedDataStringsOnly = {
name: "Eve",
address: "123 Main St",
email: "eve@example.com"
};
// const invalidStringData: MixedDataStringsOnly = { name: "Frank", age: 40 }; // <-- Lỗi compile: 'age' không tồn tại
Giải thích:
[P in keyof T]
: Lặp qua các keys củaMixedData
.as T[P] extends string ? P : never
: Đây là logic lọc.T[P] extends string
: Kiểm tra xem kiểu của thuộc tínhP
trongMixedData
có phải là kiểu con củastring
hay không (ví dụ:string
extendsstring
là đúng,number
extendsstring
là sai).? P : never
: Nếu điều kiện đúng, giữ lại tên thuộc tínhP
. Nếu sai, sử dụngnever
, khiến thuộc tính này bị loại bỏ.
T[P]
: Giữ nguyên kiểu dữ liệu gốc của thuộc tính đó (đối với các thuộc tính được giữ lại).
Thêm/Loại bỏ Modifier (readonly/optional)
Như đã thấy trong các ví dụ Partial
và Required
, chúng ta có thể thêm hoặc loại bỏ các modifier readonly
và optional
bằng cách sử dụng +
hoặc -
trước modifier.
+readonly
: Thêm modifierreadonly
.-readonly
: Loại bỏ modifierreadonly
.+?
: Thêm modifieroptional
.-?
: Loại bỏ modifieroptional
.
Lưu ý: Ký hiệu +
là mặc định, nên [P in keyof T]: T[P]
tương đương với [P in keyof T]+: T[P]
, và [P in keyof T]?: T[P]
tương đương với [P in keyof T]+?: T[P]
. Chúng ta thường chỉ sử dụng -
để loại bỏ modifier rõ ràng.
Ví dụ: Loại bỏ readonly
khỏi một kiểu dữ liệu
interface ImmutablePoint {
readonly x: number;
readonly y: number;
}
type MutablePoint = {
-readonly [P in keyof ImmutablePoint]: ImmutablePoint[P];
};
/*
Kết quả của MutablePoint sẽ là:
{
x: number;
y: number;
}
*/
// Ví dụ sử dụng:
const mutablePoint: MutablePoint = { x: 10, y: 20 };
mutablePoint.x = 15; // Hợp lệ vì readonly đã bị loại bỏ
Giải thích: -readonly [P in keyof ImmutablePoint]
lặp qua các keys và loại bỏ modifier readonly
khỏi mỗi thuộc tính.
Lợi ích của Mapped Types
Sử dụng Mapped Types mang lại nhiều lợi ích quan trọng:
- DRY (Don't Repeat Yourself): Tránh việc phải định nghĩa thủ công các kiểu dữ liệu mới có cấu trúc tương tự như kiểu cũ, chỉ khác về tính chất hoặc kiểu thuộc tính.
- Tái sử dụng: Dễ dàng tạo ra các biến thể của kiểu dữ liệu hiện có mà không cần sao chép toàn bộ định nghĩa.
- An toàn kiểu (Type Safety): TypeScript compiler sẽ đảm bảo rằng phép biến đổi được áp dụng đúng cách và kiểu dữ liệu kết quả là chính xác.
- Dễ bảo trì: Khi kiểu gốc thay đổi (thêm/xóa thuộc tính), các kiểu được tạo bằng Mapped Types sẽ tự động cập nhật theo, giảm thiểu lỗi do đồng bộ hóa thủ công.
- Tính biểu cảm: Mã nguồn trở nên rõ ràng hơn khi bạn thể hiện ý định biến đổi kiểu dữ liệu một cách trực tiếp.
Mapped Types là một công cụ cực kỳ mạnh mẽ trong TypeScript cho phép bạn thao tác và biến đổi các cấu trúc kiểu một cách linh hoạt và an toàn. Việc nắm vững các Mapped Types có sẵn và khả năng tạo ra các Mapped Types tùy chỉnh sẽ giúp bạn viết mã TypeScript hiệu quả, dễ bảo trì và tận dụng tối đa sức mạnh của hệ thống kiểu.
Comments