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ệu SomeType có thể gán được cho OtherType 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ện SomeType 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 truefalse, 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ểu T có thể gán cho kiểu string hay không.
  • Nếu đúng (ví dụ: khi T'hello'), kiểu kết quả là true.
  • Nếu sai (ví dụ: khi T123 hoặc boolean), 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 xem T 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 cho any[]).
  • 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:
    1. Kiểm tra xem T có phải là một kiểu mảng hay không (... extends ...[]).
    2. 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.
  • 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:
    1. (...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ào any[], trả về bất kỳ thứ gì any).
    2. infer ReturnType được đặt ở vị trí kiểu trả về của hàm. Nếu T 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ểu ReturnType.
  • 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:
    1. (...args: infer Params): Ở đây, infer Params được đặt ở vị trí của các tham số. Nếu T 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ểu Params 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ước extends 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ồm undefined hay không.
  • Lọc các thành viên của Union: Kết hợp với extendsinfer, 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 Union T. 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

There are no comments at the moment.