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 OtherType
có nghĩa là "LiệuSomeType
có thể gán được choOtherType
khô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 OtherType
là đú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 string
kiểm tra xem kiểuT
có thể gán cho kiểustring
hay không. - Nếu đúng (ví dụ: khi
T
là'hello'
), kiểu kết quả làtrue
. - Nếu sai (ví dụ: khi
T
là123
hoặ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 xemT
có 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à
true
nếu đúng,false
nế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
T
có 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[]) => any
là 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ếuT
khớp với mẫu này (nghĩa làT
là 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
T
là 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ếuT
là 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ểuParams
dưới dạng một Tuple.
- Nếu
T
là 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ếTrueType
tươ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ế
FalseType
cuố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 object
nế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ồmundefined
hay không. Lọc các thành viên của Union: Kết hợp với
extends
và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