Menu

Conditional type và infer trong TypeScript, kèm ví dụ

Conditional type chọn một trong hai kiểu dựa trên một phép kiểm tra: T extends U ? X : Y. Tìm hiểu cú pháp, cách conditional type phân phối trên union (và cách ngăn điều đó), cách trích xuất kiểu bằng infer, và cách tự viết ReturnType.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Conditional type (kiểu có điều kiện) chọn giữa hai kiểu bằng một phép kiểm tra trông giống toán tử ba ngôi của JavaScript: T extends U ? X : Y. Nếu T gán được cho U, kết quả là X; ngược lại là Y.

extends ở đây nghĩa là "gán được cho", cũng chính là quan hệ mà trình biên dịch dùng khi bạn gán một giá trị cho một biến. Conditional type chỉ tồn tại lúc biên dịch; chúng bị xóa khỏi JavaScript đầu ra.

Cú pháp

type Result = CheckedType extends TestType ? TrueType : FalseType;

Conditional type trở nên hữu ích khi đi cùng generic, khi kiểu được kiểm tra là một tham số kiểu sẽ nhận kiểu cụ thể sau này. Trong nhánh true, TypeScript biết kiểu được kiểm tra khớp với điều kiện, nên T["message"] ở trên được chấp nhận dù T đơn thuần không có thuộc tính message.

Chúng cũng lồng nhau được, giống các toán tử ba ngôi nối tiếp:

as TypeName<T> trong thân hàm là bắt buộc, như phần tiếp theo sẽ cho thấy.

Kiểu trả về có điều kiện cần một assertion

Một hàm có kiểu trả về là conditional type dựa trên chính tham số kiểu của nó không thể trả về trực tiếp nhánh nào. TypeScript không thu hẹp T bên trong thân hàm, nên không biết nhánh nào được áp dụng:

index.ts(5,34): error TS2322: Type 'number' is not assignable to type 'Flip<T>'.
index.ts(5,45): error TS2322: Type 'string' is not assignable to type 'Flip<T>'.

Có hai cách sửa phổ biến: overload, khai báo từng cặp đầu vào và đầu ra và kiểm tra chính xác nơi gọi, hoặc một assertion trong phần hiện thực.

Overload được trình bày ở trang nạp chồng hàm. Với assertion, trình biên dịch tin bạn: một nhánh sai trong thân hàm sẽ không bị phát hiện.

Distributive conditional type

Khi kiểu được kiểm tra là một tham số kiểu đứng riêng và nó nhận vào một union, điều kiện chạy một lần cho mỗi thành viên và các kết quả được gộp thành một union mới:

Cơ chế phân phối này là thứ giúp Exclude và Extract hoạt động. Exclude<T, U> được định nghĩa là T extends U ? never : T: mỗi thành viên khớp với U trở thành never, và never biến mất khỏi union. Vì vậy Exclude<"a" | "b" | "c", "a"> là "b" | "c".

Cùng quy tắc đó sinh ra hai điều bất ngờ. boolean là union true | false, nên ToArray<boolean> là false[] | true[], không phải boolean[]. Và never là union rỗng, nên distributive conditional type nhận never sẽ trả về never mà không kiểm tra gì cả:

type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>; // never, not true

type IsNeverFixed<T> = [T] extends [never] ? true : false;
type Y = IsNeverFixed<never>; // true

Trích xuất kiểu bằng infer

infer khai báo một biến kiểu mới bên trong mệnh đề extends. Nếu so khớp thành công, TypeScript điền biến đó từ kiểu được kiểm tra, và bạn có thể dùng nó trong nhánh true:

Hãy đọc T extends Promise<infer V> ? V : T là "nếu T là promise của một thứ gì đó, gọi thứ đó là V và trả về nó; ngược lại trả về T như cũ". infer chỉ được phép dùng trong mệnh đề extends của conditional type.

Một biến infer có thể mang ràng buộc riêng bằng extends. Khi đó phép so khớp chỉ thành công nếu kiểu suy ra được thỏa ràng buộc:

type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;

type A = FirstString<["a", 1]>; // "a"
type B = FirstString<[1, "a"]>; // never: the first element is not a string

Tự viết ReturnType

ReturnType có sẵn là một conditional type dùng infer chỉ trong một dòng. Tự viết nó là bài tập kinh điển giúp bạn hiểu rõ cả hai ý tưởng:

typeof makeUser biến giá trị hàm thành kiểu của nó, sau đó conditional type so khớp kiểu này với "một hàm bất kỳ" và bắt kiểu trả về vào R. Phiên bản trong thư viện chuẩn khác ở hai chi tiết: tham số của nó bị ràng buộc là kiểu hàm (T extends (...args: any) => any), nên ReturnType<string> là lỗi biên dịch chứ không phải never, và nhánh false của nó là any. Trang ReturnType trình bày cả Parameters, InstanceType và Awaited.

Conditional type đệ quy

Một conditional type có thể tham chiếu đến chính nó, nhờ vậy gỡ được mọi tầng lồng nhau:

type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;

type A = Flatten<number[][][]>; // number
type B = Flatten<string>;       // string

Awaited<T> có sẵn hoạt động theo cách này, gỡ Promise<Promise<T>> xuống còn T. Trong thực tế, hãy giữ đệ quy nông: đệ quy quá sâu hoặc không giới hạn khiến trình biên dịch bỏ cuộc với error TS2589: Type instantiation is excessively deep and possibly infinite.

Tra cứu nhanh

MẫuÝ nghĩa
T extends U ? X : YX nếu T gán được cho U, ngược lại là Y
T extends U ? never : Tbỏ các thành viên khớp với U (đây là Exclude)
T extends U ? T : nevergiữ các thành viên khớp với U (đây là Extract)
[T] extends [U] ? X : Ycùng phép kiểm tra, nhưng không phân phối trên union
T extends (infer E)[] ? E : Tkiểu phần tử của mảng
T extends Promise<infer V> ? V : Tkiểu giá trị của promise
T extends (...args: any[]) => infer R ? R : neverkiểu trả về của hàm
T extends [infer H, ...infer Rest] ? ...phần tử đầu và phần còn lại của tuple

Câu hỏi thường gặp

Conditional type trong TypeScript là gì?

Là một kiểu có dạng T extends U ? X : Y. Nếu T gán được cho U, kết quả là X, ngược lại là Y. Đây là câu lệnh if/else dành cho kiểu, được tính lúc biên dịch; không có gì của nó tồn tại trong JavaScript được sinh ra.

Từ khóa infer trong TypeScript dùng để làm gì?

infer khai báo một biến kiểu bên trong mệnh đề extends của conditional type và để TypeScript tự điền nó từ kiểu được so khớp. T extends Promise<infer V> ? V : T lấy ra kiểu giá trị mà một promise trả về. Từ khóa này chỉ dùng được trong mệnh đề extends của conditional type.

Distributive conditional type là gì?

Khi kiểu được kiểm tra là một tham số kiểu đứng riêng và bạn truyền vào một union, điều kiện được áp dụng cho từng thành viên riêng rẽ rồi các kết quả được gộp lại. ToArray<string | number> với type ToArray<T> = T extends unknown ? T[] : never trở thành string[] | number[]. Bọc cả hai vế trong ngoặc vuông, [T] extends [unknown], để tắt cơ chế này.

Làm sao lấy kiểu trả về của một hàm trong TypeScript?

Dùng ReturnType<typeof fn> có sẵn. Nó là một conditional type dùng infer: T extends (...args: any) => infer R ? R : any. Với hàm async, bọc nó trong Awaited<...> để lấy giá trị đã resolve thay vì promise.

Vì sao IsNever<never> trả về never thay vì true?

never là union rỗng, và distributive conditional type duyệt qua các thành viên của union. Không có thành viên nào thì không có gì để duyệt, nên kết quả là never. Viết [T] extends [never] ? true : false để kiểm tra chính never.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu