Mapped type tạo một kiểu đối tượng mới bằng cách lặp qua một tập khóa. { [K in keyof T]: boolean } nghĩa là "với mỗi khóa K của T, có một thuộc tính tên K kiểu boolean":
Thêm một khóa vào Features thì Flags<Features> cũng đòi khóa đó. Mapped type chỉ tồn tại lúc biên dịch: chúng mô tả đối tượng, không tạo ra đối tượng.
Cú pháp
Một mapped type có ba phần: biến khóa, một union các khóa để lặp qua, và kiểu thuộc tính, có thể dùng biến khóa.
type MappedType = {
[K in Keys]: PropertyType; // K takes each member of Keys in turn
};
Keys có thể là bất kỳ union nào của chuỗi, số hoặc symbol. Nó không nhất thiết phải đến từ keyof:
Đây chính xác là việc Record<Size, number> làm; Record là một mapped type chỉ một dòng. Kiểu thuộc tính có thể dùng khóa: { [K in keyof T]: T[K] } sao chép từng kiểu thuộc tính qua một indexed access, còn { [K in keyof T]: T[K] | null } khiến mọi thuộc tính có thể null.
Modifier: readonly và ? với + và -
Mapped type có thể thêm hoặc bỏ modifier readonly và tùy chọn (?) trên mọi thuộc tính cùng lúc. Đặt tiền tố + để thêm (mặc định khi bạn không viết gì) và - để bỏ:
Dòng cuối cho thấy readonly chỉ là quy tắc lúc biên dịch: phép gán bị báo lỗi (và được bỏ qua ở đây bằng @ts-expect-error), nhưng JavaScript được sinh ra vẫn chạy nó. -? làm nhiều hơn việc bỏ dấu hỏi: nó còn bỏ undefined khỏi kiểu thuộc tính, nên AllRequired<Account> từ chối { id: 1, email: undefined } với error TS2322: Type 'undefined' is not assignable to type 'string'.
Partial, Readonly, Pick và Record được tạo ra như thế nào
Các utility type có sẵn dùng để định hình lại đối tượng đều là mapped type. Đây là các định nghĩa từ chính lib.es5.d.ts của TypeScript:
type Partial<T> = { [P in keyof T]?: T[P] };
type Required<T> = { [P in keyof T]-?: T[P] };
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends keyof any, T> = { [P in K]: T };
Đọc chúng là cách luyện tập tốt: Pick chỉ lặp qua các khóa bạn truyền vào, còn Record bỏ qua mọi kiểu nguồn và gán cùng một kiểu giá trị cho mọi khóa. Omit không phải là một mapped type riêng: nó là Pick<T, Exclude<keyof T, K>>. Trang tra cứu utility type liệt kê tất cả.
Đổi tên khóa bằng as
Mệnh đề as sau khóa thay đổi tên thuộc tính. Kết hợp với template literal type, nó có thể sinh tên mới từ tên cũ:
string & K có mặt vì keyof T có thể chứa khóa number và symbol, trong khi Capitalize chỉ nhận chuỗi. Phép intersection giữ lại các khóa chuỗi và bỏ phần còn lại.
Lọc khóa bằng never
Nếu mệnh đề as cho ra never với một khóa, khóa đó bị xóa. Với conditional type, bạn có thể giữ hoặc bỏ thuộc tính dựa trên kiểu của nó:
Các kiểu như DataOnly hữu ích để mô tả những gì còn lại sau JSON.stringify hoặc những gì một form chỉnh sửa: các trường dữ liệu, không kèm phương thức.
Modifier nào được giữ lại
Một mapped type lặp qua keyof T với một kiểu T nào đó được gọi là homomorphic, và nó sao chép modifier readonly và ? của từng thuộc tính từ T. Một mapped type lặp qua một union khóa thông thường thì bắt đầu mà không có modifier nào:
type Account = { readonly id: number; email?: string };
type Copy<T> = { [K in keyof T]: T[K] };
type A = Copy<Account>;
// { readonly id: number; email?: string } (modifiers kept)
type B = { [K in "id" | "email"]: Account[K] };
// { id: number; email: string | undefined } (email is required now)
B vẫn có undefined trong kiểu của email, vì Account["email"] chứa nó, nhưng bản thân thuộc tính không còn là tùy chọn: { id: 1 } bị từ chối. Đây là lý do Partial<T> và các kiểu tương tự lặp qua keyof T: chúng giữ nguyên mọi thứ mà chúng không chủ động thay đổi.
Một mapped type homomorphic dạng generic áp dụng lên mảng hoặc tuple sẽ cho ra mảng hoặc tuple, không phải đối tượng với khóa dạng số. Readonly<string[]> là readonly string[], và với type Box<T> = { [K in keyof T]: { value: T[K] } }, Box<[string, number]> là [{ value: string }, { value: number }].
Mapped type và code lúc chạy
Vì mapped type chỉ mô tả một đối tượng, hàm tạo ra đối tượng đó cần vòng lặp riêng lúc chạy. Khi đó kiểu sẽ mô tả kết quả của hàm:
Các callback lấy kiểu tham số từ mapped type (v là string ở chỗ này và number ở chỗ kia) mà không cần chú thích nào. Nếu bạn đến đây để tìm lớp Map chứ không phải phép ánh xạ kiểu, hãy xem Map trong TypeScript.
Câu hỏi thường gặp
Mapped type trong TypeScript là gì?
Là kiểu tạo ra một kiểu đối tượng bằng cách lặp qua một union các khóa: { [K in Keys]: SomeType }. Thường thì các khóa là keyof T, nên kiểu mới có cùng các khóa với T nhưng kiểu thuộc tính đã được biến đổi, như trong type Flags<T> = { [K in keyof T]: boolean }.
+readonly, -readonly, +? và -? trong mapped type nghĩa là gì?
Chúng thêm hoặc bỏ modifier. readonly hoặc +readonly khiến mọi thuộc tính thành readonly, -readonly bỏ readonly. ? hoặc +? khiến mọi thuộc tính thành tùy chọn, -? khiến chúng bắt buộc và đồng thời bỏ undefined khỏi kiểu của chúng. Required<T> được viết bằng -?.
Làm sao để đổi tên khóa trong mapped type?
Dùng mệnh đề as sau khóa: { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] } biến name thành getName. Ánh xạ một khóa thành never trong mệnh đề as sẽ xóa khóa đó, và đây là cách lọc thuộc tính.
Mapped type có giống Map trong TypeScript không?
Không. Mapped type là phép biến đổi kiểu lúc biên dịch và không sinh ra code nào. Map<K, V> là lớp Map của JavaScript, một tập hợp các cặp khóa và giá trị lúc chạy. Các lượt tìm "typescript map type" thường muốn nói một trong hai thứ này.
Partial và Readonly được cài đặt như thế nào?
Là các mapped type trong thư viện chuẩn: type Partial<T> = { [P in keyof T]?: T[P] } và type Readonly<T> = { readonly [P in keyof T]: T[P] }. Pick và Record cũng là mapped type; Omit là Pick kết hợp với Exclude.