Bài 14.1: Conditional types trong TypeScript
Bài 14.1: Conditional types trong TypeScript
Chào mừng bạn đến với một bài học thú vị về _sức mạnh biểu đạt_ của TypeScript! Hôm nay, chúng ta sẽ đi sâu vào một trong những tính năng mạnh mẽ nhất của hệ thống kiểu TypeScript: Conditional Types (Kiểu có điều kiện). Tính năng này cho phép bạn định nghĩa kiểu dữ liệu dựa trên các mối quan hệ giữa các kiểu, tạo ra sự linh hoạt đáng kinh ngạc trong mã của bạn.
Conditional Types là gì?
Hiểu một cách đơn giản, Conditional Types cho phép bạn chọn giữa hai kiểu dữ liệu khác nhau dựa trên việc liệu một kiểu có mở rộng (extend) một kiểu khác hay không. Cú pháp của nó trông rất giống với toán tử điều kiện ba ngôi (? :) trong JavaScript hoặc các ngôn ngữ khác:
SomeType extends OtherType ? TrueType : FalseType;
Trong đó:
SomeType: Kiểu mà bạn đang kiểm tra.extends: Từ khóa dùng để kiểm tra mối quan hệ khả gán (assignability).SomeType extends OtherTypecó nghĩa là "LiệuSomeTypecó thể gán được choOtherTypekhông?". Điều này mạnh mẽ hơn kiểm tra sự bằng nhau đơn thuần.OtherType: Kiểu màSomeTypeđược so sánh với.TrueType: Kiểu kết quả nếu điều kiệnSomeType extends OtherTypelà đúng.FalseType: Kiểu kết quả nếu điều kiện là sai.
Hãy nghĩ về nó như một câu lệnh if/else ở cấp độ kiểu dữ liệu. Điều này mở ra cánh cửa để tạo ra các kiểu _động_, thay đổi hành vi tùy thuộc vào các kiểu đầu vào khác.
Bắt đầu với Ví dụ Đơn Giản
Để làm nóng, chúng ta hãy xem một ví dụ cơ bản. Giả sử bạn muốn tạo một kiểu giúp kiểm tra xem một kiểu bất kỳ có phải là string hay không. Kết quả trả về sẽ là kiểu true hoặc false (lưu ý: đây là các kiểu literal true và false, không phải giá trị boolean).
type IsString<T> = T extends string ? true : false;
// Sử dụng:
type TypeA = IsString<'hello'>; // TypeA là kiểu true
type TypeB = IsString<123>; // TypeB là kiểu false
type TypeC = IsString<boolean>; // TypeC là kiểu false
Giải thích:
- Chúng ta định nghĩa một kiểu generic
IsString<T>. - Điều kiện
T extends stringkiểm tra xem kiểuTcó thể gán cho kiểustringhay không. - Nếu đúng (ví dụ: khi
Tlà'hello'), kiểu kết quả làtrue. - Nếu sai (ví dụ: khi
Tlà123hoặcboolean), kiểu kết quả làfalse.
Đây là một ví dụ rất đơn giản, nhưng nó minh họa rõ ràng cách hoạt động của cú pháp extends ? :.
Một ví dụ khác: kiểm tra xem một kiểu có phải là mảng hay không.
type IsArray<T> = T extends any[] ? true : false;
// Sử dụng:
type Arr1 = IsArray<[1, 2, 3]>; // Arr1 là kiểu true (vì [1, 2, 3] có thể gán cho any[])
type Arr2 = IsArray<string[]>; // Arr2 là kiểu true (vì string[] có thể gán cho any[])
type Arr3 = IsArray<string>; // Arr3 là kiểu false
type Arr4 = IsArray<{ a: number }>; // Arr4 là kiểu false
Giải thích:
T extends any[]kiểm tra xemTcó phải là một kiểu mảng bất kỳ hay không (vì bất kỳ kiểu mảng nào cũng có thể gán choany[]).- Kết quả là
truenếu đúng,falsenếu sai.
Các ví dụ này cho thấy Conditional Types cho phép chúng ta thực hiện các _phép toán_ trên các kiểu dữ liệu.
Sức mạnh Thực sự: Kết hợp với Generics và infer
Conditional Types trở nên _cực kỳ_ mạnh mẽ khi được kết hợp với Generics (Kiểu chung) và từ khóa infer. Đây là nơi mà bạn có thể bắt đầu xây dựng các utility types (kiểu tiện ích) phức tạp, thao tác và chiết xuất các phần của kiểu khác.
Từ khóa infer: "Bắt lấy" một phần của Kiểu
Từ khóa infer chỉ có thể được sử dụng trong vế bên phải của mệnh đề extends trong một Conditional Type. Mục đích của nó là _giới thiệu_ một biến kiểu mới và _suy luận_ (infer) kiểu của biến đó từ kiểu mà bạn đang kiểm tra.
Hãy quay lại ví dụ kiểm tra mảng, nhưng lần này, nếu nó là mảng, chúng ta muốn lấy kiểu của các phần tử bên trong mảng đó. Nếu không phải mảng, chúng ta trả về kiểu ban đầu.
type UnwrapArray<T> = T extends (infer ElementType)[] ? ElementType : T;
// Sử dụng:
type MyArray = [1, 2, 3, 4];
type ElementOfMyArray = UnwrapArray<MyArray>; // ElementOfMyArray là kiểu number
type MyString = "hello world";
type ElementOfMyString = UnwrapArray<MyString>; // ElementOfMyString là kiểu string (vì MyString không extends (infer ElementType)[], nó rơi vào vế FalseType)
type MyNumberArray = Array<string | number>;
type ElementOfMyNumberArray = UnwrapArray<MyNumberArray>; // ElementOfMyNumberArray là kiểu string | number
Giải thích:
T extends (infer ElementType)[]: Điều này làm hai việc:- Kiểm tra xem
Tcó phải là một kiểu mảng hay không (... extends ...[]). - Nếu đúng, nó sẽ _suy luận_ và gán kiểu của các phần tử trong mảng đó cho biến kiểu mới
ElementType.
- Kiểm tra xem
- Nếu điều kiện đúng, kết quả là
ElementType(kiểu phần tử). - Nếu điều kiện sai, kết quả là
T(kiểu ban đầu).
Đây là một ví dụ kinh điển cho thấy infer giúp chúng ta "nhìn vào bên trong" một kiểu và trích xuất thông tin kiểu hữu ích.
Chiết xuất Kiểu Trả về của Hàm
Một trường hợp sử dụng rất phổ biến của infer là để lấy kiểu trả về của một hàm. TypeScript có sẵn utility type ReturnType<T> làm điều này, nhưng chúng ta có thể tự tạo ra nó bằng Conditional Types và infer.
type GetReturnType<T> = T extends (...args: any[]) => infer ReturnType ? ReturnType : never;
// Sử dụng:
function greet(name: string): string {
return `Hello, ${name}!`;
}
function sum(a: number, b: number): number {
return a + b;
}
type GreetReturnType = GetReturnType<typeof greet>; // GreetReturnType là kiểu string
type SumReturnType = GetReturnType<typeof sum>; // SumReturnType là kiểu number
type NotAFunctionReturnType = GetReturnType<number>; // NotAFunctionReturnType là kiểu never (vì number không phải hàm)
Giải thích:
T extends (...args: any[]) => infer ReturnType:(...args: any[]) => anylà cú pháp để khớp với bất kỳ kiểu hàm nào (nhận bất kỳ đối số nàoany[], trả về bất kỳ thứ gìany).infer ReturnTypeđược đặt ở vị trí kiểu trả về của hàm. NếuTkhớp với mẫu này (nghĩa làTlà một kiểu hàm), TypeScript sẽ suy luận kiểu trả về thực tế của hàm đó và gán cho biến kiểuReturnType.
- Nếu
Tlà kiểu hàm, kết quả làReturnType. - Nếu không, kết quả là
never(một kiểu đặc biệt trong TypeScript biểu thị một giá trị không bao giờ xảy ra, thường dùng cho các trường hợp không mong muốn về kiểu).
Chiết xuất Kiểu Tham số của Hàm
Tương tự, chúng ta có thể dùng infer để lấy các kiểu tham số của một hàm dưới dạng một Tuple. TypeScript cung cấp Parameters<T>.
type GetParameters<T> = T extends (...args: infer Params) => any ? Params : never;
// Sử dụng:
function calculate(x: number, y: number, operation: string): number {
// ...
return 0;
}
type CalculateParams = GetParameters<typeof calculate>; // CalculateParams là kiểu [x: number, y: number, operation: string] (một Tuple)
type GreetParams = GetParameters<typeof greet>; // GreetParams là kiểu [name: string] (một Tuple)
type EmptyParams = GetParameters<() => void>; // EmptyParams là kiểu [] (một Tuple rỗng)
type NotAFunctionParams = GetParameters<string>; // NotAFunctionParams là kiểu never
Giải thích:
T extends (...args: infer Params) => any:(...args: infer Params): Ở đây,infer Paramsđược đặt ở vị trí của các tham số. NếuTlà một kiểu hàm, TypeScript sẽ suy luận kiểu của các tham số và gán cho biến kiểuParamsdưới dạng một Tuple.
- Nếu
Tlà kiểu hàm, kết quả làParams(Tuple chứa kiểu tham số). - Nếu không, kết quả là
never.
Các ví dụ với infer này cho thấy Conditional Types không chỉ giúp kiểm tra mà còn giúp phân tích cấu trúc của các kiểu phức tạp (như mảng hoặc hàm) và trích xuất các thành phần của chúng.
Conditional Types Lồng nhau
Bạn có thể lồng ghép các Conditional Types để tạo ra các điều kiện phức tạp hơn, kiểm tra nhiều trường hợp liên tiếp.
type CheckType<T> = T extends string ? 'isString'
: T extends number ? 'isNumber'
: T extends boolean ? 'isBoolean'
: T extends undefined ? 'isUndefined'
: T extends null ? 'isNull'
: T extends object ? 'isObject'
: T extends any[] ? 'isArray'
: 'unknown';
// Sử dụng:
type TypeInfo1 = CheckType<'hello'>; // TypeInfo1 là kiểu 'isString'
type TypeInfo2 = CheckType<123>; // TypeInfo2 là kiểu 'isNumber'
type TypeInfo3 = CheckType<true>; // TypeInfo3 là kiểu 'isBoolean'
type TypeInfo4 = CheckType<{ a: 1 }>; // TypeInfo4 là kiểu 'isObject'
type TypeInfo5 = CheckType<[1, 2]>; // TypeInfo5 là kiểu 'isArray' (Lưu ý: Array cũng là object, nhưng điều kiện extends any[] được check trước extends object)
type TypeInfo6 = CheckType<undefined>; // TypeInfo6 là kiểu 'isUndefined'
type TypeInfo7 = CheckType<null>; // TypeInfo7 là kiểu 'isNull'
type TypeInfo8 = CheckType<symbol>; // TypeInfo8 là kiểu 'unknown'
Giải thích:
- Cú pháp này hoạt động giống như một chuỗi
if/else if/elseở cấp độ kiểu. - TypeScript kiểm tra lần lượt từng điều kiện. Ngay khi một điều kiện
T extends SomeTypeđúng, nó sẽ trả về kiểu ở vếTrueTypetương ứng và dừng lại. - Nếu không có điều kiện nào khớp, nó sẽ trả về kiểu ở vế
FalseTypecuối cùng ('unknown'). - Thứ tự các điều kiện quan trọng, đặc biệt với các kiểu có quan hệ cha-con hoặc chồng chéo (ví dụ: array là object, nên
extends any[]nên được kiểm tra trướcextends objectnếu bạn muốn phân biệt rõ ràng).
Ứng dụng Thực tế Khác
Conditional Types được sử dụng rộng rãi trong các thư viện TypeScript tiên tiến và để tạo ra các utility types tùy chỉnh. Một vài ví dụ:
- Loại bỏ thuộc tính tùy chọn (
Optional): Bạn có thể tạo kiểu mới chỉ bao gồm các thuộc tính bắt buộc của một kiểu khác bằng cách kiểm tra liệu kiểu của thuộc tính có bao gồmundefinedhay không. Lọc các thành viên của Union: Kết hợp với
extendsvàinfer, Conditional Types cho phép bạn "lọc" hoặc "trích xuất" các thành viên cụ thể từ một kiểu Union (ví dụ:string | number | boolean).// Utility type có sẵn trong TS: Lấy ra các thành viên khớp với U từ T type Extract<T, U> = T extends U ? T : never; type AllPossibleIDs = string | number | { id: number }; type NumberIDs = Extract<AllPossibleIDs, number>; // NumberIDs là kiểu number // Utility type có sẵn trong TS: Loại bỏ các thành viên gán được cho U khỏi T type Exclude<T, U> = T extends U ? never : T; type RemainingIDs = Exclude<AllPossibleIDs, number | string>; // RemainingIDs là kiểu { id: number }Giải thích:
Extract<T, U>: Duyệt qua từng thành viên của UnionT. Nếu thành viên đóextends U, giữ lại thành viên đó (T); nếu không, loại bỏ nó (never). Kết quả cuối cùng là một Union mới chỉ gồm các thành viên khớp.Exclude<T, U>: Ngược lại, nếu thành viên đóextends U, loại bỏ nó (never); nếu không, giữ lại thành viên đó (T). Kết quả là Union mới gồm các thành viên không khớp.- TypeScript xử lý Conditional Types trên Union bằng cách phân phối (distribute) điều kiện trên từng thành viên của Union. Đây là một cơ chế nâng cao được gọi là Distributive Conditional Types.
Tạo kiểu cho các hàm quá tải (Overloaded Functions): Conditional Types giúp bạn mô tả kiểu trả về hoặc tham số khác nhau tùy thuộc vào signature nào của hàm quá tải được gọi.
Comments