Ràng buộc generic (generic constraint) giới hạn những gì một tham số kiểu có thể là. <T extends { length: number }> chỉ nhận những kiểu có length kiểu số (chuỗi, mảng, đối tượng do bạn tự định nghĩa), và nhờ cam kết đó mà hàm được phép đọc .length.
Kiểu trả về vẫn là T, không phải { length: number }: truyền vào hai mảng số thì nhận lại number[], và các đối tượng vẫn giữ tag của chúng. Ràng buộc mô tả mức tối thiểu; kiểu của nơi gọi được giữ nguyên.
Vì sao cần ràng buộc
Bên trong một hàm generic, một T không có ràng buộc có thể là bất cứ thứ gì: một chuỗi, một số, null, một hàm. Vì vậy TypeScript chỉ cho phép những gì dùng được với mọi kiểu, mà như thế thì gần như chẳng có gì.
Trình biên dịch in ra:
index.ts(3,12): error TS2339: Property 'length' does not exist on type 'T'.
index.ts(3,24): error TS2339: Property 'length' does not exist on type 'T'.
Cách sửa là nói rõ bạn cần gì: <T extends { length: number }>. Ràng buộc biến "bất kỳ kiểu nào" thành "bất kỳ kiểu nào có thứ này", và đó thường chính là ý của một hàm generic.
Ràng buộc theo một interface
Ràng buộc có thể là bất kỳ kiểu nào, kể cả một interface hay type alias bạn đã có. Khi đó hàm làm việc với mọi thứ có ít nhất những thành viên đó, và vẫn trả về đầy đủ kiểu của nơi gọi.
So sánh với một kiểu tham số thông thường: function byId(items: HasId[], id: number): HasId | undefined nhận cùng những mảng đó, nhưng kết quả chỉ là HasId, nên found.name sẽ là lỗi. Phiên bản generic truyền nguyên vẹn kiểu đầy đủ ra ngoài.
K extends keyof T: truy cập thuộc tính an toàn
Ràng buộc phổ biến nhất liên kết hai tham số kiểu với nhau. K extends keyof T nói rằng K phải là một trong các tên thuộc tính của T, và T[K] là kiểu của thuộc tính đó.
Một khóa bị gõ sai hoặc không tồn tại là lỗi biên dịch, và kiểu trả về đi theo khóa: "year" cho ra number, "title" cho ra string. Trang keyof trình bày chính keyof, kể cả cách nó hoạt động với index signature.
Ràng buộc theo kiểu nguyên thủy
Ràng buộc có thể là một kiểu nguyên thủy hoặc một union của chúng. Một hiệu ứng phụ hữu ích: khi T extends string (hoặc number), TypeScript suy ra kiểu literal của đối số thay vì mở rộng nó.
Không có ràng buộc, createEvent("user.saved") trả về { type: string }: ở đây TypeScript mở rộng literal mà nó suy ra cho T thành string. Với T extends string, literal được giữ lại, và đó là cách các hàm trợ giúp cho sự kiện và định tuyến có kiểu lấy được tên chính xác từ các đối số chuỗi thông thường.
Lỗi thường gặp
Trả về thứ chỉ khớp với ràng buộc. Một hàm trả về T phải trả về một T, chứ không phải bất kỳ giá trị nào vừa với ràng buộc:
interface HasId {
id: number;
}
function reset<T extends HasId>(item: T): T {
return { id: item.id };
}
// error TS2322: Type '{ id: number; }' is not assignable to type 'T'.
// '{ id: number; }' is assignable to the constraint of type 'T', but 'T' could be
// instantiated with a different subtype of constraint 'HasId'.
Nếu T là { id: number; name: string }, đối tượng mới không có name, nên nó không phải là T. Hãy trả về một bản spread của đầu vào, return { ...item, id: 0 };, để giữ mọi thuộc tính, hoặc khai báo kiểu trả về là HasId nếu bạn chỉ tạo ra đúng thế.
Truyền một đối số kiểu phá vỡ ràng buộc. Đối số kiểu viết rõ cũng được kiểm tra: với function scale<T extends number>(x: T), lời gọi scale<string>("2") thất bại với Type 'string' does not satisfy the constraint 'number'. (TS2344).
Dùng ràng buộc khi không cần generic. Nếu T chỉ xuất hiện trong tham số, function print<T extends HasId>(item: T): void cũng chỉ là function print(item: HasId): void với thêm cú pháp. Ràng buộc có ý nghĩa khi T còn xuất hiện trong kiểu trả về hoặc trong một tham số khác.
Ràng buộc kèm giá trị mặc định
Một tham số kiểu có thể có cả ràng buộc lẫn giá trị mặc định. Giá trị mặc định phải thỏa mãn ràng buộc.
interface Store<TState extends object = Record<string, unknown>> {
get(): TState;
set(next: Partial<TState>): void;
}
type AnyStore = Store; // Store<Record<string, unknown>>
type CounterStore = Store<{ count: number }>; // fine
type BadStore = Store<number>;
// error TS2344: Type 'number' does not satisfy the constraint 'object'.
Cùng từ khóa extends cũng xuất hiện trong conditional type, T extends string ? A : B, nơi nó là một phép thử chứ không phải một giới hạn; trang conditional type trình bày dạng đó.
Câu hỏi thường gặp
T extends trong generic của TypeScript nghĩa là gì?
<T extends Constraint> nghĩa là T có thể là bất kỳ kiểu nào gán được cho Constraint. Nơi gọi chỉ truyền được những kiểu như vậy, và bên trong hàm bạn được dùng mọi thứ mà ràng buộc đảm bảo. <T extends { length: number }> nhận chuỗi, mảng và mọi đối tượng có length kiểu số, và cho phép bạn đọc value.length.
K extends keyof T nghĩa là gì?
K phải là một trong các tên thuộc tính của T. Kết hợp với indexed access type T[K], nó khai báo kiểu chính xác cho việc truy cập thuộc tính: function get<T, K extends keyof T>(obj: T, key: K): T[K] chỉ nhận những khóa thật của obj, và kiểu trả về là kiểu của thuộc tính đó.
Vì sao tôi gặp lỗi "Property does not exist on type T"?
Một T không có ràng buộc có thể là bất kỳ kiểu nào, kể cả number hay null, nên TypeScript không cho phép thao tác nào đặc thù cho một kiểu trên nó (TS2339). Hãy thêm một ràng buộc đảm bảo có thuộc tính đó: <T extends { name: string }>, hoặc ràng buộc theo một interface bạn đã có, <T extends User>.
"T could be instantiated with a different subtype of constraint" nghĩa là gì?
Lỗi TS2322: hàm hứa trả về T, nhưng lại trả về một giá trị chỉ khớp với ràng buộc. Nếu T là { id: number; name: string }, thì một { id: 1 } mới không phải là T. Hãy trả về giá trị bạn nhận được (hoặc một bản spread của nó, với kiểu tương ứng), hoặc đổi kiểu trả về thành chính ràng buộc.
extends trong generic khác extends trong lớp hay interface thế nào?
Cùng một từ khóa, ý tưởng có liên quan. Trong class Dog extends Animal và interface B extends A, nó khai báo kế thừa. Trong <T extends A>, nó khai báo một ràng buộc: T phải gán được cho A. Trong một conditional type, T extends A ? X : Y, nó là một phép thử để chọn một trong hai kiểu.