Hướng dẫn toàn diện về Mapped Types và Conditional Types mạnh mẽ của TypeScript, bao gồm ví dụ thực tế và các trường hợp sử dụng nâng cao để tạo ứng dụng mạnh mẽ và an toàn về kiểu.
Làm Chủ Mapped Types và Conditional Types trong TypeScript
TypeScript, một tập hợp cha của JavaScript, cung cấp các tính năng mạnh mẽ để tạo ra các ứng dụng vững chắc và dễ bảo trì. Trong số các tính năng này, Mapped Types và Conditional Types nổi bật như những công cụ thiết yếu để thao tác kiểu nâng cao. Hướng dẫn này cung cấp một cái nhìn tổng quan toàn diện về các khái niệm này, khám phá cú pháp, ứng dụng thực tế và các trường hợp sử dụng nâng cao của chúng. Dù bạn là một nhà phát triển TypeScript dày dạn kinh nghiệm hay chỉ mới bắt đầu, bài viết này sẽ trang bị cho bạn kiến thức để tận dụng hiệu quả các tính năng này.
Mapped Types là gì?
Mapped Types cho phép bạn tạo ra các kiểu mới bằng cách biến đổi các kiểu đã có. Chúng lặp qua các thuộc tính của một kiểu hiện có và áp dụng một phép biến đổi cho mỗi thuộc tính. Điều này đặc biệt hữu ích để tạo ra các biến thể của các kiểu hiện có, chẳng hạn như đặt tất cả thuộc tính thành tùy chọn hoặc chỉ đọc.
Cú pháp cơ bản
Cú pháp của một Mapped Type như sau:
type NewType<T> = {
[K in keyof T]: Transformation;
};
T
: Kiểu đầu vào mà bạn muốn ánh xạ.K in keyof T
: Lặp qua mỗi khóa trong kiểu đầu vàoT
.keyof T
tạo ra một union của tất cả các tên thuộc tính trongT
, vàK
đại diện cho mỗi khóa riêng lẻ trong quá trình lặp.Transformation
: Phép biến đổi bạn muốn áp dụng cho mỗi thuộc tính. Điều này có thể là thêm một bổ từ (nhưreadonly
hoặc?
), thay đổi kiểu, hoặc một cái gì đó hoàn toàn khác.
Ví dụ thực tế
Đặt thuộc tính thành chỉ đọc
Giả sử bạn có một interface đại diện cho hồ sơ người dùng:
interface UserProfile {
name: string;
age: number;
email: string;
}
Bạn có thể tạo một kiểu mới trong đó tất cả các thuộc tính đều là chỉ đọc:
type ReadOnlyUserProfile = {
readonly [K in keyof UserProfile]: UserProfile[K];
};
Bây giờ, ReadOnlyUserProfile
sẽ có các thuộc tính giống như UserProfile
, nhưng tất cả chúng đều là chỉ đọc.
Đặt thuộc tính thành tùy chọn
Tương tự, bạn có thể đặt tất cả các thuộc tính thành tùy chọn:
type OptionalUserProfile = {
[K in keyof UserProfile]?: UserProfile[K];
};
OptionalUserProfile
sẽ có tất cả các thuộc tính của UserProfile
, nhưng mỗi thuộc tính sẽ là tùy chọn.
Sửa đổi kiểu thuộc tính
Bạn cũng có thể sửa đổi kiểu của mỗi thuộc tính. Ví dụ, bạn có thể biến đổi tất cả các thuộc tính thành chuỗi:
type StringifiedUserProfile = {
[K in keyof UserProfile]: string;
};
Trong trường hợp này, tất cả các thuộc tính trong StringifiedUserProfile
sẽ có kiểu là string
.
Conditional Types là gì?
Conditional Types cho phép bạn định nghĩa các kiểu phụ thuộc vào một điều kiện. Chúng cung cấp một cách để thể hiện các mối quan hệ kiểu dựa trên việc một kiểu có thỏa mãn một ràng buộc cụ thể hay không. Điều này tương tự như toán tử ba ngôi trong JavaScript, nhưng dành cho kiểu.
Cú pháp cơ bản
Cú pháp của một Conditional Type như sau:
T extends U ? X : Y
T
: Kiểu đang được kiểm tra.U
: Kiểu màT
kế thừa (điều kiện).X
: Kiểu sẽ trả về nếuT
kế thừaU
(điều kiện đúng).Y
: Kiểu sẽ trả về nếuT
không kế thừaU
(điều kiện sai).
Ví dụ thực tế
Xác định một kiểu có phải là chuỗi không
Hãy tạo một kiểu trả về string
nếu kiểu đầu vào là một chuỗi, và number
trong trường hợp ngược lại:
type StringOrNumber<T> = T extends string ? string : number;
type Result1 = StringOrNumber<string>; // string
type Result2 = StringOrNumber<number>; // number
type Result3 = StringOrNumber<boolean>; // number
Trích xuất kiểu từ một Union
Bạn có thể sử dụng các kiểu điều kiện để trích xuất một kiểu cụ thể từ một kiểu union. Ví dụ, để trích xuất các kiểu không thể là null:
type NonNullable<T> = T extends null | undefined ? never : T;
type Result4 = NonNullable<string | null | undefined>; // string
Ở đây, nếu T
là null
hoặc undefined
, kiểu sẽ trở thành never
, sau đó được lọc ra bởi cơ chế đơn giản hóa kiểu union của TypeScript.
Suy luận kiểu
Các kiểu điều kiện cũng có thể được sử dụng để suy luận kiểu bằng từ khóa infer
. Điều này cho phép bạn trích xuất một kiểu từ một cấu trúc kiểu phức tạp hơn.
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
function myFunction(x: number): string {
return x.toString();
}
type Result5 = ReturnType<typeof myFunction>; // string
Trong ví dụ này, ReturnType
trích xuất kiểu trả về của một hàm. Nó kiểm tra xem T
có phải là một hàm nhận bất kỳ đối số nào và trả về một kiểu R
hay không. Nếu đúng, nó trả về R
; nếu không, nó trả về any
.
Kết hợp Mapped Types và Conditional Types
Sức mạnh thực sự của Mapped Types và Conditional Types đến từ việc kết hợp chúng. Điều này cho phép bạn tạo ra các phép biến đổi kiểu linh hoạt và biểu cảm cao.
Ví dụ: Deep Readonly (Chỉ đọc sâu)
Một trường hợp sử dụng phổ biến là tạo ra một kiểu làm cho tất cả các thuộc tính của một đối tượng, bao gồm cả các thuộc tính lồng nhau, thành chỉ đọc. Điều này có thể đạt được bằng cách sử dụng một kiểu điều kiện đệ quy.
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Company {
name: string;
address: {
street: string;
city: string;
};
}
type ReadonlyCompany = DeepReadonly<Company>;
Ở đây, DeepReadonly
áp dụng bổ từ readonly
một cách đệ quy cho tất cả các thuộc tính và các thuộc tính lồng nhau của chúng. Nếu một thuộc tính là một đối tượng, nó sẽ gọi đệ quy DeepReadonly
trên đối tượng đó. Nếu không, nó chỉ đơn giản áp dụng bổ từ readonly
cho thuộc tính đó.
Ví dụ: Lọc thuộc tính theo kiểu
Giả sử bạn muốn tạo một kiểu chỉ bao gồm các thuộc tính của một kiểu cụ thể. Bạn có thể kết hợp Mapped Types và Conditional Types để đạt được điều này.
type FilterByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
interface Person {
name: string;
age: number;
isEmployed: boolean;
}
type StringProperties = FilterByType<Person, string>; // { name: string; }
type NonStringProperties = Omit<Person, keyof StringProperties>;
Trong ví dụ này, FilterByType
lặp qua các thuộc tính của T
và kiểm tra xem kiểu của mỗi thuộc tính có kế thừa U
không. Nếu có, nó sẽ bao gồm thuộc tính đó trong kiểu kết quả; nếu không, nó sẽ loại trừ thuộc tính bằng cách ánh xạ khóa tới never
. Lưu ý việc sử dụng "as" để ánh xạ lại các khóa. Sau đó, chúng ta sử dụng `Omit` và `keyof StringProperties` để loại bỏ các thuộc tính chuỗi khỏi interface ban đầu.
Các trường hợp sử dụng và mẫu nâng cao
Ngoài các ví dụ cơ bản, Mapped Types và Conditional Types có thể được sử dụng trong các kịch bản nâng cao hơn để tạo ra các ứng dụng có khả năng tùy biến cao và an toàn về kiểu.
Kiểu điều kiện phân phối (Distributive Conditional Types)
Các kiểu điều kiện có tính phân phối khi kiểu được kiểm tra là một kiểu union. Điều này có nghĩa là điều kiện được áp dụng cho từng thành viên của union một cách riêng lẻ, và kết quả sau đó được kết hợp thành một kiểu union mới.
type ToArray<T> = T extends any ? T[] : never;
type Result6 = ToArray<string | number>; // string[] | number[]
Trong ví dụ này, ToArray
được áp dụng cho từng thành viên của union string | number
một cách riêng lẻ, tạo ra kết quả là string[] | number[]
. Nếu điều kiện không có tính phân phối, kết quả sẽ là (string | number)[]
.
Sử dụng Utility Types
TypeScript cung cấp một số utility type tích hợp sẵn tận dụng Mapped Types và Conditional Types. Các utility type này có thể được sử dụng làm khối xây dựng cho các phép biến đổi kiểu phức tạp hơn.
Partial<T>
: Làm cho tất cả các thuộc tính củaT
thành tùy chọn.Required<T>
: Làm cho tất cả các thuộc tính củaT
thành bắt buộc.Readonly<T>
: Làm cho tất cả các thuộc tính củaT
thành chỉ đọc.Pick<T, K>
: Chọn một tập hợp các thuộc tínhK
từT
.Omit<T, K>
: Loại bỏ một tập hợp các thuộc tínhK
khỏiT
.Record<K, T>
: Xây dựng một kiểu với một tập hợp các thuộc tínhK
có kiểuT
.Exclude<T, U>
: Loại bỏ khỏiT
tất cả các kiểu có thể gán choU
.Extract<T, U>
: Trích xuất từT
tất cả các kiểu có thể gán choU
.NonNullable<T>
: Loại bỏnull
vàundefined
khỏiT
.Parameters<T>
: Lấy các tham số của một kiểu hàmT
.ReturnType<T>
: Lấy kiểu trả về của một kiểu hàmT
.InstanceType<T>
: Lấy kiểu instance của một kiểu hàm khởi tạoT
.
Các utility type này là những công cụ mạnh mẽ có thể đơn giản hóa các thao tác kiểu phức tạp. Ví dụ, bạn có thể kết hợp Pick
và Partial
để tạo ra một kiểu chỉ làm cho một số thuộc tính nhất định trở thành tùy chọn:
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface Product {
id: number;
name: string;
price: number;
description: string;
}
type OptionalDescriptionProduct = Optional<Product, "description">;
Trong ví dụ này, OptionalDescriptionProduct
có tất cả các thuộc tính của Product
, nhưng thuộc tính description
là tùy chọn.
Sử dụng Template Literal Types
Template Literal Types cho phép bạn tạo các kiểu dựa trên các chuỗi ký tự. Chúng có thể được sử dụng kết hợp với Mapped Types và Conditional Types để tạo ra các phép biến đổi kiểu động và biểu cảm. Ví dụ, bạn có thể tạo một kiểu có tiền tố là một chuỗi cụ thể cho tất cả các tên thuộc tính:
type Prefix<T, P extends string> = {
[K in keyof T as `${P}${string & K}`]: T[K];
};
interface Settings {
apiUrl: string;
timeout: number;
}
type PrefixedSettings = Prefix<Settings, "data_">;
Trong ví dụ này, PrefixedSettings
sẽ có các thuộc tính data_apiUrl
và data_timeout
.
Các phương pháp hay nhất và lưu ý
- Giữ cho đơn giản: Mặc dù Mapped Types và Conditional Types rất mạnh mẽ, chúng cũng có thể làm cho mã của bạn trở nên phức tạp hơn. Cố gắng giữ cho các phép biến đổi kiểu của bạn càng đơn giản càng tốt.
- Sử dụng Utility Types: Tận dụng các utility type tích hợp sẵn của TypeScript bất cứ khi nào có thể. Chúng đã được kiểm thử kỹ lưỡng và có thể đơn giản hóa mã của bạn.
- Ghi chú tài liệu cho kiểu của bạn: Ghi chú rõ ràng cho các phép biến đổi kiểu của bạn, đặc biệt nếu chúng phức tạp. Điều này sẽ giúp các nhà phát triển khác hiểu mã của bạn.
- Kiểm tra kiểu của bạn: Sử dụng cơ chế kiểm tra kiểu của TypeScript để đảm bảo rằng các phép biến đổi kiểu của bạn hoạt động như mong đợi. Bạn có thể viết unit test để xác minh hành vi của các kiểu của mình.
- Cân nhắc hiệu suất: Các phép biến đổi kiểu phức tạp có thể ảnh hưởng đến hiệu suất của trình biên dịch TypeScript. Hãy chú ý đến độ phức tạp của các kiểu của bạn và tránh các tính toán không cần thiết.
Kết luận
Mapped Types và Conditional Types là các tính năng mạnh mẽ trong TypeScript cho phép bạn tạo ra các phép biến đổi kiểu linh hoạt và biểu cảm cao. Bằng cách làm chủ các khái niệm này, bạn có thể cải thiện tính an toàn về kiểu, khả năng bảo trì và chất lượng tổng thể của các ứng dụng TypeScript của mình. Từ các phép biến đổi đơn giản như làm cho thuộc tính trở thành tùy chọn hoặc chỉ đọc, đến các phép biến đổi đệ quy phức tạp và logic điều kiện, những tính năng này cung cấp các công cụ bạn cần để xây dựng các ứng dụng vững chắc và có khả năng mở rộng. Hãy tiếp tục khám phá và thử nghiệm với các tính năng này để khai thác hết tiềm năng của chúng và trở thành một nhà phát triển TypeScript thành thạo hơn.
Khi bạn tiếp tục hành trình với TypeScript, hãy nhớ tận dụng nguồn tài nguyên phong phú có sẵn, bao gồm tài liệu chính thức của TypeScript, các cộng đồng trực tuyến và các dự án mã nguồn mở. Hãy nắm bắt sức mạnh của Mapped Types và Conditional Types, và bạn sẽ được trang bị tốt để giải quyết ngay cả những vấn đề liên quan đến kiểu phức tạp nhất.