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ạttá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ả stringarray đề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à 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ảistring 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ột string) là một kiểu con của HasLength (vì string có thuộc tính length: number). TypeScript cho phép.
  • Khi bạn gọi printLengthSafe([1, 2, 3]), kiểu [1, 2, 3] (một number[], tức là một Array<number>) là một kiểu con của HasLength (vì Array có thuộc tính length: number). TypeScript cho phép.
  • Khi bạn cố gọi printLengthSafe(123), kiểu number không phải là kiểu con của HasLength. 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ủa HasLength. 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 extendskeyof, 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ểu K phải là một kiểu mà có thể gán được cho keyof T (tức là một trong các tên thuộc tính của T).
  • 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ên K trong kiểu object T". Khi gọi getProperty(myObject, 'name'), T được suy luận là typeof myObjectK đượ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ủa Animal. Bất cứ nơi nào cần Animal, bạn có thể dùng Dog.
  • Một string là kiểu con của string | number. Bất cứ nơi nào cần string | number, bạn có thể dùng string.
  • Một object { length: number, name: string } là kiểu con của HasLength ({ 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

There are no comments at the moment.