TypeScript có thể mô tả quan hệ mà union và interface thông thường không biểu diễn được. Một hàm có thể trả về cấu trúc khác nhau cho chuỗi và mảng; một API client có thể gắn mỗi endpoint với phản hồi và lỗi riêng; một thư viện có thể lấy kiểu đã resolve từ promise mà không biết trước giá trị ấy.
Kiểu điều kiện biến các mối quan hệ đó thành thứ có thể lập trình. Cú pháp cơ bản giống toán tử ba ngôi, nhưng được đánh giá trong hệ thống kiểu:
type MessageOf<Value> = Value extends { message: unknown }
? Value['message']
: never;
type EmailMessage = MessageOf<{ message: string; sentAt: Date }>;
// string
type NoMessage = MessageOf<{ code: number }>;
// never
Kiểu điều kiện tương tác với union, any, never, suy luận, kiểu ánh xạ và đệ quy theo những cách dễ đọc nhầm. Mục tiêu không phải màn “thể dục kiểu” phức tạp nhất, mà là mã hóa bảo đảm hữu ích bằng mã nguồn dễ hiểu.
Mô hình tư duy cho phép rẽ nhánh ở cấp kiểu
Hãy đọc Value extends Constraint ? Yes : No như câu hỏi: “Value có gán được cho Constraint không?” Đây là quan hệ khả gán, không phải kế thừa class. Một object type mở rộng kiểu khác khi nó có đủ thuộc tính bắt buộc, dù hai khai báo không liên quan tường minh.
type JsonScalar<Value> = Value extends string | number | boolean | null
? Value
: never;
type A = JsonScalar<'ready'>; // 'ready'
type B = JsonScalar<Date>; // never
Với tham số generic, trình biên dịch hoãn quyết định đến lúc biết tham số. Nhờ vậy, alias bảo toàn quan hệ thay vì suy rộng thành union chung chung.
type LabelFor<Value> = Value extends number
? { kind: 'numeric'; value: Value }
: { kind: 'text'; value: string };
declare function makeLabel<Value extends string | number>(
value: Value,
): LabelFor<Value>;
const count = makeLabel(42);
// { kind: 'numeric'; value: 42 }
const caption = makeLabel('forty-two');
// { kind: 'text'; value: string }
Mẫu này thay được danh sách overload dài khi một quy tắc generic giải thích toàn bộ quan hệ. Overload tốt hơn nếu API chỉ có vài chữ ký riêng biệt, hoặc kiểu trả về có điều kiện buộc phần triển khai ép kiểu liên tục.
| Công cụ | Phù hợp nhất khi | Đánh đổi chính |
|---|---|---|
| Union | Một giá trị có thể thuộc một trong vài cấu trúc đã biết | Tự nó không bảo toàn tương quan đầu vào - đầu ra |
| Function overload | Có một tập nhỏ chữ ký gọi khác biệt | Lặp lại nhiều khi số tổ hợp tăng |
| Kiểu điều kiện | Một kiểu phụ thuộc có quy luật vào kiểu khác | Phải cẩn thận với phân phối union và các trường hợp biên |
| Kiểu ánh xạ | Cần biến đổi mọi thuộc tính trong kiểu object | Đổi tên key phức tạp có thể che khuất ý định |
| Schema runtime | Cần kiểm tra dữ liệu không đáng tin đang thực sự tồn tại | Tốn chi phí lúc chạy và thêm phụ thuộc schema |
Note
Kiểu điều kiện bị xóa cùng mọi kiểu TypeScript khác. Nó ảnh hưởng đến quá trình kiểm tra và công cụ trong editor; nó không sinh nhánh JavaScript và không quan sát giá trị lúc chạy.
Trích xuất cấu trúc bằng infer
Trong nhánh đúng, infer tạo biến kiểu cho một phần cấu trúc đang khớp. Nó là pattern matching cho kiểu: mô tả hình dạng ngoài, đặt tên phần chưa biết rồi dùng tên đó trong kết quả.
type ElementOf<Value> = Value extends readonly (infer Element)[]
? Element
: never;
type Id = ElementOf<readonly string[]>; // string
type Mixed = ElementOf<[string, number, boolean]>;
// string | number | boolean
type ReturnOf<Value> = Value extends (...args: never[]) => infer Result
? Result
: never;
type FactoryResult = ReturnOf<() => { id: string; active: boolean }>;
// { id: string; active: boolean }
Thư viện chuẩn đã có ReturnType, Parameters, Awaited và các utility liên quan. Hãy ưu tiên chúng khi ngữ nghĩa phù hợp; chỉ viết kiểu riêng cho cấu trúc miền cụ thể hoặc nhánh dự phòng khác.
Một kiểu điều kiện đệ quy có thể bóc nhiều lớp promise:
type DeepAwaited<Value> = Value extends PromiseLike<infer Resolved>
? DeepAwaited<Resolved>
: Value;
type Loaded = DeepAwaited<Promise<Promise<{ id: string }>>>;
// { id: string }
Nhiều biến infer có thể bắt các phần tương quan trong cùng một lần. Template literal type cho phép áp dụng ý tưởng tương tự với chuỗi:
type RouteParts<Path> = Path extends `${infer Resource}/${infer Id}`
? { resource: Resource; id: Id }
: never;
type UserRoute = RouteParts<'users/42'>;
// { resource: 'users'; id: '42' }
type FunctionParts<Value> = Value extends (
...args: infer Args
) => infer Result
? { args: Args; result: Result }
: never;
type SaveParts = FunctionParts<(id: string, force?: boolean) => Promise<void>>;
// { args: [id: string, force?: boolean]; result: Promise<void> }
Pattern giới hạn suy luận. Nếu Value không khớp, nhánh sai được chọn. Biến infer cũng có thể mang constraint để giữ kết quả chính xác mà không cần điều kiện thứ hai:
type FirstString<Value> = Value extends readonly [
infer First extends string,
...unknown[],
]
? First
: never;
type Command = FirstString<['build', '--watch']>; // 'build'
type NotACommand = FirstString<[404, 'missing']>; // never
Tính phân phối, tuple wrapper và các cạnh sắc
Kiểu điều kiện phân phối trên union khi vế kiểm tra là tham số kiểu đứng trực tiếp bên trái extends, không nằm trong mảng, tuple, promise hay wrapper khác.
type ToArray<Value> = Value extends unknown ? Value[] : never;
type Distributed = ToArray<string | number>;
// string[] | number[]
Trình biên dịch đánh giá riêng ToArray<string> và ToArray<number>, rồi hợp kết quả. Cơ chế này tạo nên bộ lọc như Exclude:
type OnlyStrings<Value> = Value extends string ? Value : never;
type Names = OnlyStrings<'Ada' | 'Linus' | 404>;
// 'Ada' | 'Linus'
Đôi khi câu hỏi dành cho toàn bộ union. Bọc hai vế bằng tuple một phần tử để ngăn phân phối:
type ToArrayTogether<Value> = [Value] extends [unknown] ? Value[] : never;
type NotDistributed = ToArrayTogether<string | number>;
// (string | number)[]
type AllStrings<Value> = [Value] extends [string] ? true : false;
type Yes = AllStrings<'Ada' | 'Linus'>; // true
type No = AllStrings<'Ada' | 404>; // false
Wrapper đổi câu hỏi từ “Từng phần tử có thỏa constraint?” thành “Toàn bộ union có thỏa constraint?”
Ba kiểu đặc biệt cần được xử lý tường minh:
neverlà union rỗng. Kiểu điều kiện có tính phân phối áp dụng lênneverkhông có phần tử nào để đánh giá, nên kết quả lànever, không phải nhánh đúng hay nhánh sai.anybỏ qua cơ chế an toàn thông thường. Trong nhiều kiểu điều kiện, nó khiến cả hai nhánh cùng đóng góp vào kết quả và có thể âm thầm làm bẩn các suy luận phía sau.unknownlà top type an toàn. Mọi thứ đều gán được cho nó, nhưng không thể dùng nó như một giá trị cụ thể hơn trước khi thu hẹp.
type IsNever<Value> = [Value] extends [never] ? true : false;
type IsAny<Value> = 0 extends 1 & Value ? true : false;
type N = IsNever<never>; // true
type A = IsAny<any>; // true
type U = IsAny<unknown>; // false
Warning
Đừng xây cả một thư viện utility lớn quanh các phép dò khéo léo kiểu IsAny. Chúng đôi khi cần thiết ở ranh giới thư viện, nhưng mô hình ứng dụng thường rõ ràng hơn khi cấm any và tiếp nhận giá trị bên ngoài dưới dạng unknown.
Kết hợp kiểu điều kiện với kiểu ánh xạ
Kiểu ánh xạ duyệt key; kiểu điều kiện quyết định cách xử lý từng key hoặc value. Kết hợp lại, chúng có thể chọn thuộc tính, thay modifier và đổi tên key mà vẫn bảo toàn mô hình nguồn.
type NullableKeys<Model> = {
[Key in keyof Model]-?: null extends Model[Key] ? Key : never;
}[keyof Model];
type User = {
id: string;
displayName: string;
avatarUrl: string | null;
deletedAt?: Date | null;
};
type NullableUserKey = NullableKeys<User>;
// 'avatarUrl' | 'deletedAt'
Kiểu ánh xạ bên trong tạo object có value là key hoặc never; truy cập bằng keyof Model gom chúng thành union. never biến mất nên chỉ key phù hợp còn lại.
Key remapping có thể dùng kết quả ấy để dựng interface mới:
type EventHandlers<Events> = {
[Key in keyof Events as Key extends string
? `on${Capitalize<Key>}`
: never]: (payload: Events[Key]) => void;
};
type DomainEvents = {
saved: { id: string };
failed: { reason: string; retryable: boolean };
};
type Handlers = EventHandlers<DomainEvents>;
// {
// onSaved: (payload: { id: string }) => void;
// onFailed: (payload: { reason: string; retryable: boolean }) => void;
// }
Phép biến đổi đệ quy cần điều kiện dừng. Nếu không có nó, primitive và object dựng sẵn có thể bị mở rộng thành những bản đồ thuộc tính vô nghĩa, hoặc trình biên dịch báo quá trình khởi tạo kiểu đã đi quá sâu.
type Atomic = string | number | boolean | bigint | symbol | null | undefined;
type DeepReadonly<Value> = Value extends Atomic | Date | RegExp | Function
? Value
: Value extends readonly (infer Element)[]
? readonly DeepReadonly<Element>[]
: Value extends object
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
: Value;
Phiên bản này dừng ở giá trị nguyên tử và built-in phổ biến, xử lý mảng trước object rồi mới ánh xạ thuộc tính. Đây là policy, không phải chân lý: Map, Set, branded value, tuple và class có thể cần quy tắc khác.
Đệ quy của trình biên dịch là hữu hạn. JSON lồng sâu, parser template đệ quy hoặc union nhân nhánh có thể chạm giới hạn khởi tạo kiểu và làm editor chậm. Hãy thêm ngân sách độ sâu khi đầu vào không giới hạn:
type Previous = [never, 0, 1, 2, 3, 4, 5];
type JsonLike<Value, Depth extends number = 5> = Depth extends 0
? unknown
: Value extends Atomic
? Value
: Value extends readonly (infer Element)[]
? JsonLike<Element, Previous[Depth]>[]
: Value extends object
? { [Key in keyof Value]: JsonLike<Value[Key], Previous[Depth]> }
: never;
Tip
Hãy đặt tên cho các bước biến đổi trung gian và chủ động giới hạn đệ quy. Một chuỗi alias KeysMatching, PayloadOf và ResultFor dài hơn đôi chút vẫn dễ gỡ lỗi hơn một kiểu nén chặt làm mọi việc.
Mô hình hóa API thực tế, không tạo cảm giác an toàn giả
Kiểu điều kiện hữu ích nhất khi bảo toàn quan hệ tại ranh giới API. Xét client có payload thành công và thất bại riêng cho từng endpoint:
type Endpoints = {
getUser: {
response: { id: string; name: string };
error: { code: 'NOT_FOUND' };
};
listProjects: {
response: { items: { id: string; title: string }[]; next: string | null };
error: { code: 'UNAUTHORIZED' | 'RATE_LIMITED'; retryAfter?: number };
};
};
type EndpointName = keyof Endpoints;
type ApiResult<Name extends EndpointName> =
| { ok: true; data: Endpoints[Name]['response'] }
| { ok: false; error: Endpoints[Name]['error'] };
type ErrorCode<Name extends EndpointName> = Endpoints[Name]['error'] extends {
code: infer Code extends string;
}
? Code
: never;
type ProjectError = ErrorCode<'listProjects'>;
// 'UNAUTHORIZED' | 'RATE_LIMITED'
Tên endpoint chọn đúng kiểu kết quả. Kiểu ánh xạ có thể suy ra toàn bộ client mà không lặp chữ ký method:
type ApiClient = {
[Name in EndpointName]: () => Promise<ApiResult<Name>>;
};
declare const client: ApiClient;
const result = await client.getUser();
if (result.ok) {
result.data.name; // string
} else {
result.error.code; // 'NOT_FOUND'
}
Tương quan đó chỉ được bảo đảm lúc biên dịch. fetch().json() không được kiểm chứng vì annotation khẳng định như vậy. Dữ liệu mạng, local storage, biến môi trường và đầu vào người dùng đều vượt ranh giới tin cậy. Hãy đọc chúng thành unknown, rồi dùng schema hoặc validator trước khi trả về kết quả đã định kiểu.
import { parseApiResult } from './schema.js';
const routes: Record<EndpointName, string> = {
getUser: '/api/users/current',
listProjects: '/api/projects',
};
async function request<Name extends EndpointName>(
name: Name,
): Promise<ApiResult<Name>> {
const response = await fetch(routes[name]);
const input: unknown = await response.json();
return parseApiResult(name, response.ok, input);
}
parseApiResult phải kiểm tra discriminant, trường bắt buộc, mảng và payload riêng tại runtime. Chữ ký generic có thể bảo toàn quan hệ, nhưng phần triển khai phải đạt kiểu đó bằng kiểm chứng thay vì assertion as ApiResult<Name>.
Kiểu điều kiện phản tác dụng khi người đọc phải mô phỏng nhiều phép biến đổi chỉ để hiểu một field. Hãy export tên miền nghiệp vụ như ApiResult<'getUser'>, giữ máy móc cấp thấp ở private và kiểm thử cả trường hợp hợp lệ lẫn trường hợp phải lỗi. Nếu overload hoặc interface truyền đạt contract nhanh hơn, hãy dùng nó. Trừu tượng nên bỏ lặp lại, không bỏ ý nghĩa.
Điểm chính cần nhớ
- Kiểu điều kiện mã hóa một câu hỏi về khả năng gán và bảo toàn quan hệ có quy luật giữa các kiểu.
infertrích xuất kiểu từ cấu trúc khớp; nên ưu tiên utility dựng sẵn khi hành vi của chúng đã phù hợp.- Tham số generic đứng độc lập sẽ phân phối trên union. Tuple wrapper tắt phân phối khi cần kiểm tra cả union.
never,anyvàunknowncó ngữ nghĩa khác nhau có chủ đích. Dùngunknownlàm mặc định cho giá trị không đáng tin và chỉ dùnganytrong trường hợp ngoại lệ.- Kiểu điều kiện kết hợp tốt với kiểu ánh xạ, nhưng phép biến đổi đệ quy cần base case rõ ràng và đôi khi cần ngân sách độ sâu.
- Kiểu API chỉ giúp caller an toàn sau khi validation lúc chạy xác nhận dữ liệu bên ngoài khớp mô hình khai báo.
- Khả năng đọc là một phần của tính đúng đắn. Đặt tên cho kiểu trung gian, phơi bày khái niệm nghiệp vụ và dừng lại khi kiểu tường minh dễ hiểu hơn.