Bài 13.2: Constraints trong generics TypeScript

Bài 13.2: Constraints trong generics TypeScript
Bạn đã làm quen với Generics - một trong những tính năng mạnh mẽ nhất của TypeScript giúp tạo ra các component hoặc hàm có thể làm việc với nhiều kiểu dữ liệu mà vẫn giữ được tính an toàn kiểu. Generics mang lại sự linh hoạt và tái sử dụng cao cho code của bạn. Tuyệt vời!
Nhưng đôi khi, sự "linh hoạt" vô hạn của kiểu dữ liệu generic lại khiến chúng ta gặp khó khăn. Khi bạn làm việc với một kiểu generic T
, bạn không hề biết gì về cấu trúc của T
cả. Liệu T
có thuộc tính .length
không? Liệu nó có phải là một object với thuộc tính name
hay không? TypeScript, với sự chặt chẽ của mình, sẽ không cho phép bạn truy cập vào các thuộc tính hoặc phương thức của T
nếu nó không chắc chắn rằng T
có những thứ đó.
Đây chính là lúc Constraints (Ràng buộc) cho Generics tỏa sáng!
Vấn đề khi không có Constraints
Hãy xem một ví dụ đơn giản. Chúng ta muốn tạo một hàm generic nhận vào một đối số và in ra độ dài của nó. Chúng ta biết rằng cả string
và array
đều có thuộc tính .length
.
// Chúng ta muốn một hàm generic có thể in độ dài của cả string và array
function printLength<T>(arg: T): void {
// Ồ không! TypeScript báo lỗi ngay tại đây!
// Property 'length' does not exist on type 'T'.
// TypeScript không biết T là gì, nên không chắc nó có thuộc tính .length
console.log(arg.length);
}
// Nếu chạy code này (mà không bị lỗi compile), chúng ta sẽ gặp rắc rối:
// printLength("Hello World"); // OK, string có length
// printLength([1, 2, 3]); // OK, array có length
// printLength(123); // KHÔNG OK, number không có length -> Runtime error hoặc undefined
// printLength({ name: "Alice" }); // KHÔNG OK, object không có length -> Runtime error hoặc undefined
Như bạn thấy trong code bình luận, TypeScript báo lỗi ngay từ khi định nghĩa hàm printLength
. Nó không quan tâm rằng bạn có thể gọi hàm này với một string
hay một array
- những kiểu có .length
. Tại thời điểm định nghĩa hàm, TypeScript chỉ biết rằng arg
có kiểu T
, và nó không có thông tin gì về việc T
có thuộc tính length
hay không. Điều này là để đảm bảo tính an toàn kiểu của bạn.
Làm thế nào để chúng ta nói với TypeScript rằng "Kiểu generic T
này phải là một kiểu dữ liệu mà có thuộc tính length
"?
Giải pháp: Sử dụng extends
để tạo Constraints
Để giải quyết vấn đề này, chúng ta sử dụng từ khóa extends
ngay sau tên kiểu generic T
khi khai báo. Cú pháp này cho phép chúng ta định nghĩa một ràng buộc về kiểu dữ liệu mà T
có thể đại diện.
function identity<T extends string>(arg: T): T {
// OKAY: TypeScript biết T chắc chắn là string (hoặc kiểu con của string)
return arg;
}
Trong ví dụ trên, T extends string
có nghĩa là kiểu generic T
phải là string
hoặc một kiểu con (subtype) của string
. Trong thực tế, bạn hiếm khi ràng buộc với các kiểu nguyên thủy đơn giản như string
theo cách này cho các logic phức tạp, mà thường dùng extends
để ràng buộc với các interface hoặc type alias.
Ràng buộc với Interface/Type Alias: Giải quyết vấn đề .length
Cách phổ biến và mạnh mẽ nhất để sử dụng constraints là ràng buộc kiểu generic với một interface
hoặc type alias
định nghĩa các thuộc tính hoặc phương thức mà bạn cần kiểu generic đó phải có.
Hãy tạo một interface
gọi là HasLength
để mô tả bất kỳ kiểu dữ liệu nào có thuộc tính length
kiểu number
:
// Định nghĩa một interface mô tả kiểu dữ liệu mà chúng ta mong muốn T phải có
interface HasLength {
length: number;
}
// Sử dụng constraint 'extends HasLength'
// Điều này nói với TypeScript rằng: Kiểu T phải là một kiểu mà có thuộc tính length kiểu number
function printLengthSafe<T extends HasLength>(arg: T): void {
// OKAY: Bây giờ TypeScript biết chắc chắn T có thuộc tính .length kiểu number
console.log(`Độ dài: ${arg.length}`);
}
Bây giờ, hàm printLengthSafe
của chúng ta đã an toàn kiểu!
- Khi bạn gọi
printLengthSafe("Xin chào")
, kiểu"Xin chào"
(mộtstring
) là một kiểu con củaHasLength
(vìstring
có thuộc tínhlength: number
). TypeScript cho phép. - Khi bạn gọi
printLengthSafe([1, 2, 3])
, kiểu[1, 2, 3]
(mộtnumber[]
, tức là mộtArray<number>
) là một kiểu con củaHasLength
(vìArray
có thuộc tínhlength: number
). TypeScript cho phép. - Khi bạn cố gọi
printLengthSafe(123)
, kiểunumber
không phải là kiểu con củaHasLength
. TypeScript sẽ báo lỗi ngay lập tức: "Argument of type 'number' is not assignable to parameter of type 'HasLength'." - Khi bạn cố gọi
printLengthSafe({ name: "Alice" })
, kiểu{ name: string }
không phải là kiểu con củaHasLength
. TypeScript sẽ báo lỗi: "Argument of type '{ name: string; }' is not assignable to parameter of type 'HasLength'. Object literal may only specify known properties, and 'name' does not exist in type 'HasLength'."
Chúng ta đã thành công trong việc ràng buộc kiểu generic T
để chỉ chấp nhận những kiểu dữ liệu an toàn cho hoạt động mà chúng ta muốn thực hiện (.length
).
Các ví dụ Constraint nâng cao hơn
Constraints không chỉ dừng lại ở việc ràng buộc với một interface đơn giản.
Ràng buộc với nhiều kiểu (Union)
Bạn có thể ràng buộc kiểu generic với một union type, nghĩa là nó phải là một trong các kiểu trong union đó.
function processInput<T extends string | number>(input: T): T {
// OKAY: TypeScript biết input chắc chắn là string hoặc number
if (typeof input === 'string') {
return input.trim() as T; // An toàn khi ép kiểu về T vì T là string hoặc number
}
if (typeof input === 'number') {
return Math.abs(input) as T; // An toàn khi ép kiểu về T
}
// Thực tế sẽ không bao giờ đến đây vì T đã bị ràng buộc
// throw new Error("Invalid input type");
}
console.log(processInput(" Hello ")); // "Hello"
console.log(processInput(-123)); // 123
// console.log(processInput(true)); // Lỗi: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
T extends string | number
có nghĩa là T
chỉ có thể là string
, number
, hoặc các kiểu con của chúng (mặc dù với primitive type thì không có kiểu con rõ ràng theo nghĩa kế thừa).
Ràng buộc với keyof
Một pattern rất phổ biến và mạnh mẽ khi làm việc với object là ràng buộc một kiểu generic để nó phải là một key (tên thuộc tính) của một kiểu object generic khác. Chúng ta sử dụng toán tử keyof
. Toán tử keyof T
sẽ trả về một union type chứa tất cả các tên thuộc tính công khai của kiểu T
dưới dạng literal strings.
Ví dụ: keyof { name: string, age: number }
sẽ trả về "name" | "age"
.
Kết hợp extends
và keyof
, chúng ta có thể tạo một hàm generic để lấy giá trị của một thuộc tính từ một object, đảm bảo an toàn kiểu:
// T là kiểu của object, K là kiểu của key (phải là một trong các key của T)
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
// OKAY: TypeScript biết chắc chắn 'key' là một key hợp lệ của 'obj'
// và biết kiểu trả về là kiểu của thuộc tính 'key' trong obj (T[K] là Lookup Type)
return obj[key];
}
const myObject = { name: "Alice", age: 30, city: "New York" };
// OKAY: 'name' là key hợp lệ của myObject
const personName = getProperty(myObject, 'name'); // TypeScript suy luận personName có kiểu string
// OKAY: 'age' là key hợp lệ
const personAge = getProperty(myObject, 'age'); // TypeScript suy luận personAge có kiểu number
// Lỗi compile time: 'address' không phải là key hợp lệ của myObject
// getProperty(myObject, 'address'); // Lỗi: Argument of type '"address"' is not assignable to parameter of type '"name" | "age" | "city"'.
Giải thích:
getProperty<T, K extends keyof T>
: Chúng ta có hai kiểu generic.T
là kiểu của object.K
là kiểu của key.K extends keyof T
là ràng buộc quan trọng: Nó nói rằng kiểuK
phải là một kiểu mà có thể gán được chokeyof T
(tức là một trong các tên thuộc tính củaT
).keyof T
: Trong ví dụmyObject
,keyof typeof myObject
sẽ là kiểu"name" | "age" | "city"
. Do đó,K
buộc phải là một trong các chuỗi này.T[K]
: Đây gọi là Lookup Type trong TypeScript. Nó có nghĩa là "kiểu dữ liệu của thuộc tính có tênK
trong kiểu objectT
". Khi gọigetProperty(myObject, 'name')
,T
được suy luận làtypeof myObject
vàK
được suy luận là'name'
.T[K]
trở thành(typeof myObject)['name']
, tức làstring
. Thật thông minh phải không?
Constraint K extends keyof T
đảm bảo rằng bạn chỉ có thể truyền vào một key thực sự tồn tại trên object T
, mang lại an toàn kiểu vượt trội so với JavaScript thuần túy hoặc TypeScript không dùng constraint.
Vì sao lại dùng extends
cho Constraints?
Có thể bạn thắc mắc tại sao lại dùng từ khóa extends
, vốn thường liên quan đến kế thừa lớp, để định nghĩa ràng buộc kiểu generic?
Trong hệ thống kiểu của TypeScript, extends
được sử dụng rộng rãi để chỉ mối quan hệ subtype (kiểu con).
- Một lớp
Dog extends Animal
nghĩa làDog
là kiểu con củaAnimal
. Bất cứ nơi nào cầnAnimal
, bạn có thể dùngDog
. - Một
string
là kiểu con củastring | number
. Bất cứ nơi nào cầnstring | number
, bạn có thể dùngstring
. - Một object
{ length: number, name: string }
là kiểu con củaHasLength
({ length: number }
). Bất cứ nơi nào cần một object cólength
, bạn có thể dùng object này.
Khi bạn viết <T extends ConstraintType>
, bạn đang nói rằng "kiểu T
phải là một kiểu con của ConstraintType
". Điều này có nghĩa là kiểu T
ít nhất phải có tất cả các thuộc tính và phương thức mà ConstraintType
yêu cầu. Do đó, bên trong hàm generic, bạn có thể an toàn truy cập vào các thành viên được định nghĩa trong ConstraintType
vì bạn biết chắc chắn rằng T
sẽ có chúng.
Comments