Conditional Types trong TypeScript

Conditional Types trong TypeScript

Generics cho phép bạn viết code linh hoạt. Nhưng có những tình huống bạn cần chọn kiểu dựa trên điều kiện — kiểu A thì trả về X, kiểu B thì trả về Y. Đó là lúc conditional types xuất hiện. Nếu generics là "biến kiểu", conditional types là "if/else kiểu".

#1. Cú pháp cơ bản: T extends U ? X : Y

Nhìn vào đoạn code runtime quen thuộc:

function format(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase(); // string
  }
  return value.toFixed(2); // number
}

Bạn đang phân nhánh dựa trên kiểu. Conditional types làm điều tương tự ở type level:

type IsString<T> = T extends string ? true : false;
 
type A = IsString<string>;  // true ✅
type B = IsString<number>;  // false ✅
type C = IsString<"hello">; // true ✅ — string literal cũng extends string

Cấu trúc: T extends U ? X : Y

  • Nếu T assignable cho U → kết quả là X
  • Ngược lại → kết quả là Y

Giống hệt toán tử ba ngôi trong JavaScript, nhưng hoạt động trên kiểu.

#2. Conditional type trong hàm — loại bỏ overload

Giả sử bạn viết một hàm nhận vào string | number và muốn kiểu trả về khác nhau:

// ❌ Cách truyền thống: overload — dài dòng, dễ sai
function wrap(value: string): string[];
function wrap(value: number): number[];
function wrap(value: string | number): string[] | number[] {
  return [value];
}
 
// ✅ Dùng conditional type
type Wrap<T> = T extends string ? string[] : number[];
 
function wrap<T extends string | number>(value: T): Wrap<T> {
  return [value] as Wrap<T>;
}
 
const a = wrap("hello"); // string[] ✅
const b = wrap(42);      // number[] ✅

Lưu ý: as Wrap<T> vẫn cần thiết ở đây vì compiler không tự suy luận chính xác kiểu trả qua conditional type trong body hàm. Nhưng ở phía người gọi, kiểu hiển thị đúng.

#3. infer — trích xuất kiểu từ bên trong

Đây là phần mạnh nhất của conditional types. Từ infer cho phép bạn đặt một biến kiểu ở vị trí cần đoán, rồi lấy kết quả ra.

Ví dụ: bạn muốn lấy kiểu phần tử của một mảng.

// ❌ Không có infer — phải dùng indexed access, không linh hoạt
type Elem1 = string[][number]; // string — chỉ được với array literal
 
// ✅ Dùng infer
type ElementType<T> = T extends (infer E)[] ? E : never;
 
type A = ElementType<string[]>;    // string ✅
type B = ElementType<number[]>;    // number ✅
type C = ElementType<[1, 2, 3]>;  // number ✅
type D = ElementType<string>;     // never — string không phải mảng

Cách đọc: "Nếu T assignable cho E[] (mảng mà mỗi phần tử là E), thì kết quả là E. Ngược lại là never."

infer hoạt động ở bất kỳ đâu trong type expression. Ví dụ lấy kiểu trả về của hàm:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
 
type A = MyReturnType<() => string>;        // string ✅
type B = MyReturnType<(x: number) => void>; // void ✅
type C = MyReturnType<string>;              // never — string không phải hàm

Thực ra TypeScript đã có sẵn ReturnType<T> built-in, nhưng đây chính là cách nó được implement bên trong.

Lấy kiểu của Promise:

type PromiseType<T> = T extends Promise<infer U> ? U : never;
 
type A = PromiseType<Promise<number>>; // number ✅
type B = PromiseType<Promise<string[]>>; // string[] ✅
type C = PromiseType<number>; // never — number không phải Promise

#4. Nested infer — trích xuất nhiều lớp

Bạn có thể dùng nhiều infer trong một conditional type, hoặc lồng chúng:

// Lấy kiểu trả về của hàm trả Promise
type AsyncReturnType<T> = T extends (...args: any[]) => Promise<infer R>
  ? R
  : never;
 
type A = AsyncReturnType<() => Promise<number>>; // number ✅
type B = AsyncReturnType<() => Promise<User[]>>; // User[] ✅
type C = AsyncReturnType<() => number>;           // never — không trả Promise

Pattern này rất phổ biến khi test async function — bạn cần biết kiểu bên trong Promise mà không cần await.

#5. Distributive conditional types — tự động map trên union

Đây là tính năng khiến conditional types thực sự mạnh. Khi T là một union type, conditional type tự động phân phối qua từng thành phần:

type ToArray<T> = T extends any ? T[] : never;
 
type A = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[] ✅

Mỗi thành phần của union được xử lý riêng, rồi kết quả được union lại. Đây gọi là distributive conditional type.

So sánh với cách không phân phối:

// ❌ Bọc trong tuple để TẮT tính distributive
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
 
type B = ToArrayNonDist<string | number>;
// = (string | number)[] ✅ — khác với trên!

(string | number)[] khác string[] | number[]:

  • (string | number)[]: mảng có thể chứa cả string lẫn number
  • string[] | number[]: mảng chỉ chứa toàn string hoặc toàn number

#6. Anti-pattern: vô tình tắt distributive

Đây là lỗi cực kỳ phổ biến. Giả sử bạn muốn lọc union — giữ lại chỉ những thành phần assignable cho string:

// ✅ Đúng — distributive
type FilterString<T> = T extends string ? T : never;
 
type A = FilterString<string | number | boolean>;
// = FilterString<string> | FilterString<number> | FilterString<boolean>
// = string | never | never
// = string ✅
// ❌ Sai — vô tình bọc trong [], tắt distributive
type FilterStringBroken<T> = [T] extends [string] ? T : never;
 
type B = FilterStringBroken<string | number | boolean>;
// [string | number | boolean] extends [string]?
// Không — nên kết quả là never ❌

Khi bạn bọc T trong [T] hoặc (...), bạn đang kiểm tra toàn bộ union thay vì từng thành phần. Đây là cách tắt distributive khi bạn cố ý muốn, nhưng nếu vô tình thì sẽ ra kết quả sai hoàn toàn.

Quy tắc: nếu T xuất hiện trực tiếp trước extends → distributive. Nếu T nằm trong [], (), hoặc bất kỳ wrapper nào → không distributive.

#7. Ví dụ thực tế: ExcludeExtract

TypeScript có sẵn Exclude<T, U>Extract<T, U>. Đây là cách chúng hoạt động:

// Exclude: loại bỏ những thành phần trong T assignable cho U
type MyExclude<T, U> = T extends U ? never : T;
 
type A = MyExclude<"a" | "b" | "c", "a">;
// = "b" | "c" ✅
 
// Extract: giữ lại những thành phần trong T assignable cho U
type MyExtract<T, U> = T extends U ? T : never;
 
type B = MyExtract<"a" | "b" | "c", "a" | "b">;
// = "a" | "b" ✅

Exclude hoạt động bằng trick: match thì trả never (loại bỏ), không match thì giữ nguyên T. Đây là ứng dụng trực tiếp của distributive conditional type.

#8. Non-null loại bỏ với conditional type

NonNullable<T> loại bỏ nullundefined:

type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type A = MyNonNullable<string | null | undefined>;
// = string | never | never
// = string ✅

Một lần nữa, distributive conditional type làm việc nặng.

#Bài tập

Làm lần lượt từ dễ đến khó. Viết code trong widget bên dưới, hệ thống sẽ chấm và giải thích lỗi cho bạn.

Đang tải bài tập...
Đang tải bài tập...
Đang tải bài tập...

#Tổng kết

Những điểm cần khắc sâu sau bài này:

  1. Conditional type có cấu trúc T extends U ? X : Y — viết if/else ngay trong type system.
  2. infer đặt biến kiểu ở vị trí cần đoán, cho phép trích xuất kiểu sâu bên trong (return type, element type, Promise resolved type).
  3. Distributive conditional type tự phân phối qua union — mỗi thành phần được xử lý riêng rồi union lại kết quả.
  4. Bọc T trong [T] tắt distributive — hữu ích khi bạn muốn kiểm tra toàn bộ union cùng lúc, nhưng vô tình thì sẽ ra kết quả sai.
  5. Exclude, Extract, NonNullable đều là ứng dụng trực tiếp của distributive conditional type.

Buổi tới: Mapped Types — cách biến đổi mọi thuộc tính trong một object type cùng lúc, kết hợp với conditional types để tạo kiểu cực kỳ linh hoạt.