Bài 13.5: Bài tập thực hành generics với TypeScript

Chào mừng bạn đến với bài thực hành chuyên sâu về Generics trong TypeScript! Nếu bạn đã làm quen với các khái niệm cơ bản, thì đây chính là lúc để nắm vững sức mạnh và sự linh hoạt mà Generics mang lại. Generics cho phép bạn viết các thành phần có thể hoạt động với bất kỳ kiểu dữ liệu nào, trong khi vẫn giữ được sự an toàn kiểu dữ liệu (type safety) mà TypeScript cung cấp. Đây là một công cụ không thể thiếu khi xây dựng các thư viện, reusable components, hoặc chỉ đơn giản là viết code dễ bảo trìít lỗi hơn.

Bài viết này sẽ tập trung vào việc thực hành thông qua các ví dụ cụ thể, từ cơ bản đến nâng cao một chút, giúp bạn thấy rõ cách áp dụng Generics trong nhiều tình huống khác nhau. Hãy cùng bắt đầu!

1. Generics với Functions: Cơ bản và Mạnh mẽ

Cách đơn giản nhất để bắt đầu với Generics là áp dụng chúng vào các hàm. Một hàm Generic là hàm có thể hoạt động với nhiều kiểu dữ liệu khác nhau mà không làm mất thông tin về kiểu.

Ví dụ 1: Hàm identity

Hàm identity là một ví dụ kinh điển. Nó đơn giản chỉ trả về đối số mà nó nhận được. Với JavaScript, điều này dễ dàng:

function identityJS(arg) {
  return arg;
}

Nhưng với TypeScript và không dùng Generics, bạn sẽ mất thông tin về kiểu cụ thể:

function identityAny(arg: any): any {
  return arg;
}

let numberResult = identityAny(123); // Kiểu là 'any'
let stringResult = identityAny("hello"); // Kiểu là 'any'

// Khi kiểu là 'any', TypeScript không thể kiểm tra lỗi nếu bạn cố gắng
// gọi một phương thức không tồn tại trên kiểu gốc:
// numberResult.toFixed(); // OK (vì numberResult có thể là number)
// numberResult.toUpperCase(); // Sẽ không báo lỗi ở compile-time, nhưng lỗi ở runtime!

Sử dụng Generics, chúng ta có thể giữ nguyên thông tin kiểu:

function identityTS<T>(arg: T): T {
  return arg;
}

// Sử dụng hàm identityTS:

// Cách 1: TypeScript tự suy luận kiểu T
let numberResultTS = identityTS(123); // TypeScript suy luận T là 'number'
console.log(typeof numberResultTS); // Output: number

let stringResultTS = identityTS("hello"); // TypeScript suy luận T là 'string'
console.log(typeof stringResultTS); // Output: string

let booleanResultTS = identityTS(true); // TypeScript suy luận T là 'boolean'

// Bây giờ, TypeScript sẽ kiểm tra kiểu chặt chẽ hơn:
// numberResultTS.toUpperCase(); // Lỗi compile-time: Property 'toUpperCase' does not exist on type 'number'. Tuyệt vời!

Giải thích:

  • <T> ngay sau tên hàm (identityTS) là cách khai báo rằng đây là một hàm Generic và T là một biến kiểu (type variable).
  • arg: T cho biết đối số arg sẽ có kiểu là T.
  • : T ở cuối chữ ký hàm cho biết kiểu trả về cũng là T.
  • Khi gọi hàm identityTS(123), TypeScript nhìn vào đối số 123 và "suy luận" rằng biến kiểu T phải là number. Do đó, chữ ký hàm trở thành identityTS(arg: number): number.
  • Khi gọi identityTS("hello"), TypeScript suy luận Tstring, và chữ ký trở thành identityTS(arg: string): string.

Điều này đảm bảo rằng kiểu dữ liệu được truyền qua hàm mà không bị mất đi, mang lại type safety tuyệt vời.

Ví dụ 2: Hàm Generic với mảng

Generics rất hữu ích khi làm việc với các cấu trúc dữ liệu như mảng.

// Hàm lấy phần tử đầu tiên của một mảng bất kỳ
function getFirstElement<T>(arr: T[]): T | undefined {
  if (arr.length === 0) {
    return undefined;
  }
  return arr[0];
}

let numbers = [10, 20, 30];
let firstNumber = getFirstElement(numbers); // TypeScript suy luận T là 'number', firstNumber có kiểu 'number | undefined'
console.log(firstNumber); // Output: 10

let strings = ["apple", "banana", "cherry"];
let firstString = getFirstElement(strings); // TypeScript suy luận T là 'string', firstString có kiểu 'string | undefined'
console.log(firstString); // Output: apple

let emptyArray: number[] = [];
let resultFromEmpty = getFirstElement(emptyArray); // TypeScript suy luận T là 'number', resultFromEmpty có kiểu 'number | undefined'
console.log(resultFromEmpty); // Output: undefined

Giải thích:

  • arr: T[] nghĩa là đối số arr là một mảng chứa các phần tử có kiểu T.
  • Kiểu trả về T | undefined nghĩa là hàm sẽ trả về một phần tử có kiểu T hoặc undefined nếu mảng rỗng.
  • Khi gọi getFirstElement(numbers), numbersnumber[], nên TypeScript suy luận Tnumber.
  • Khi gọi getFirstElement(strings), stringsstring[], nên T được suy luận là string.

Điều này cho phép chúng ta viết một hàm getFirstElement duy nhất hoạt động đúng kiểu cho bất kỳ loại mảng nào.

2. Generics với Interfaces: Xây dựng cấu trúc dữ liệu linh hoạt

Generics không chỉ áp dụng cho hàm mà còn rất mạnh mẽ khi định nghĩa các interface. Điều này giúp bạn tạo ra các cấu trúc dữ liệu mà kiểu của các thuộc tính bên trong phụ thuộc vào một kiểu được chỉ định khi sử dụng interface đó.

Ví dụ 3: Interface Box Generic

Hãy định nghĩa một interface mô tả một "hộp" chứa một giá trị bất kỳ.

interface Box<T> {
  value: T;
}

// Sử dụng interface Box:

let numberBox: Box<number> = { value: 123 };
console.log(numberBox.value); // Output: 123 (Kiểu là number)

let stringBox: Box<string> = { value: "hello generics" };
console.log(stringBox.value); // Output: hello generics (Kiểu là string)

// Bạn không thể gán một kiểu khác với kiểu đã khai báo:
// let booleanBox: Box<boolean> = { value: "true" }; // Lỗi compile-time: Type 'string' is not assignable to type 'boolean'.

Giải thích:

  • interface Box<T> định nghĩa một interface Generic với biến kiểu T.
  • Bên trong interface, thuộc tính value được định nghĩa có kiểu là T.
  • Khi bạn khai báo một biến sử dụng Box, bạn phải chỉ định kiểu cụ thể cho T bên trong dấu ngoặc nhọn <>. Ví dụ: Box<number> hoặc Box<string>.
  • TypeScript sau đó sẽ kiểm tra chặt chẽ để đảm bảo thuộc tính value tuân thủ kiểu đã chỉ định.

Interface Generic rất hữu ích khi mô tả các cấu trúc dữ liệu phổ biến như danh sách liên kết (linked lists), cây (trees), hoặc các đối tượng tùy chỉnh mà giá trị cốt lõi có thể thuộc nhiều kiểu khác nhau.

Ví dụ 4: Interface Generic với nhiều thuộc tính

Interface Generic có thể có nhiều thuộc tính sử dụng cùng biến kiểu hoặc các biến kiểu khác.

interface Container<T> {
  id: number;
  item: T;
  description: string;
}

let productContainer: Container<{ name: string, price: number }> = {
  id: 1,
  item: { name: "Laptop", price: 1200 },
  description: "A powerful laptop"
};

console.log(productContainer.item.name); // Output: Laptop (Kiểu của item là { name: string, price: number })

let statusContainer: Container<string> = {
  id: 2,
  item: "Pending",
  description: "Current order status"
};

console.log(statusContainer.item.toUpperCase()); // Output: PENDING (Kiểu của item là string)

Giải thích:

  • interface Container<T> sử dụng biến kiểu T cho thuộc tính item. Các thuộc tính khác như iddescription có kiểu cố định (number, string).
  • Khi sử dụng Container, bạn chỉ định kiểu của item. Ví dụ: Container<{ name: string, price: number }> hoặc Container<string>.

3. Generics với Classes: Xây dựng các lớp linh hoạt

Tương tự như hàm và interface, các lớp (classes) cũng có thể sử dụng Generics để hoạt động với nhiều kiểu dữ liệu khác nhau.

Ví dụ 5: Lớp DataStorage Generic

Hãy xây dựng một lớp đơn giản để lưu trữ dữ liệu của một kiểu nhất định.

class DataStorage<T> {
  private data: T[] = [];

  addItem(item: T) {
    this.data.push(item);
  }

  removeItem(item: T) {
    // Lưu ý: removeItem hoạt động tốt với primitive types (string, number, boolean)
    // Đối với objects, nó sẽ chỉ xóa instance *giống hệt* về mặt tham chiếu
    const index = this.data.indexOf(item);
    if (index > -1) {
      this.data.splice(index, 1);
    }
  }

  getItems(): T[] {
    // Trả về bản sao để tránh thay đổi mảng gốc từ bên ngoài
    return [...this.data];
  }
}

// Sử dụng lớp DataStorage:

// Storage cho số
const numberStorage = new DataStorage<number>();
numberStorage.addItem(10);
numberStorage.addItem(20);
// numberStorage.addItem('30'); // Lỗi compile-time! Phải là number

console.log("Number storage:", numberStorage.getItems()); // Output: Number storage: [ 10, 20 ]
numberStorage.removeItem(10);
console.log("Number storage after remove:", numberStorage.getItems()); // Output: Number storage after remove: [ 20 ]


// Storage cho chuỗi
const stringStorage = new DataStorage<string>();
stringStorage.addItem("Apple");
stringStorage.addItem("Banana");
// stringStorage.addItem(123); // Lỗi compile-time! Phải là string

console.log("String storage:", stringStorage.getItems()); // Output: String storage: [ 'Apple', 'Banana' ]
stringStorage.removeItem("Apple");
console.log("String storage after remove:", stringStorage.getItems()); // Output: String storage after remove: [ 'Banana' ]

// Storage cho đối tượng (cẩn thận với removeItem)
const objectStorage = new DataStorage<object>();
const obj1 = { name: "Max" };
const obj2 = { name: "Manu" };
objectStorage.addItem(obj1);
objectStorage.addItem(obj2);

console.log("Object storage:", objectStorage.getItems()); // Output: Object storage: [ { name: 'Max' }, { name: 'Manu' } ]

// remove obj1 (theo tham chiếu)
objectStorage.removeItem(obj1);
console.log("Object storage after remove obj1:", objectStorage.getItems()); // Output: Object storage after remove obj1: [ { name: 'Manu' } ]

// remove một đối tượng mới có cùng giá trị (sẽ không hoạt động vì khác tham chiếu)
objectStorage.removeItem({ name: "Manu" });
console.log("Object storage after remove {name: 'Manu'}:", objectStorage.getItems()); // Output: Object storage after remove {name: 'Manu' } : [ { name: 'Manu' } ] - Vẫn còn!

Giải thích:

  • class DataStorage<T> khai báo lớp Generic với biến kiểu T.
  • Thuộc tính data được định nghĩa là một mảng T[].
  • Các phương thức addItem, removeItem, getItems đều sử dụng biến kiểu T để đảm bảo tính nhất quán.
  • Khi tạo một instance của lớp (new DataStorage<number>()), bạn chỉ định kiểu dữ liệu mà instance đó sẽ xử lý.
  • TypeScript đảm bảo rằng chỉ các giá trị có kiểu T mới có thể được thêm vào hoặc xử lý bởi instance đó.

Lớp Generic là nền tảng để xây dựng các cấu trúc dữ liệu tùy chỉnh như stacks, queues, lists, v.v., mà không cần viết lại logic cho từng kiểu dữ liệu.

4. Ràng buộc Generic (Generic Constraints): Giới hạn kiểu dữ liệu

Đôi khi, bạn muốn các hàm hoặc lớp Generic của mình chỉ hoạt động với các kiểu dữ liệu có các thuộc tính nhất định. Đây là lúc sử dụng ràng buộc (constraints) với từ khóa extends.

Ví dụ 6: Hàm Generic với ràng buộc Lengthwise

Giả sử bạn muốn viết một hàm Generic có thể in ra độ dài của đối số, nhưng chỉ khi đối số đó có thuộc tính .length.

// Định nghĩa một interface mô tả thuộc tính cần có
interface Lengthwise {
  length: number;
}

// Hàm Generic chỉ chấp nhận các kiểu có thuộc tính 'length'
function loggingIdentityWithConstraint<T extends Lengthwise>(arg: T): T {
  console.log(arg.length); // OK vì T được đảm bảo có thuộc tính .length
  return arg;
}

// Sử dụng hàm với ràng buộc:

// Chuỗi có thuộc tính .length
loggingIdentityWithConstraint("hello constraint"); // OK. Output: 18

// Mảng có thuộc tính .length
loggingIdentityWithConstraint([10, 20, 30, 40]); // OK. Output: 4

// Số KHÔNG có thuộc tính .length
// loggingIdentityWithConstraint(3); // Lỗi compile-time: Argument of type 'number' is not assignable to parameter of type 'Lengthwise'. Tuyệt vời!

// Đối tượng không có thuộc tính .length
// loggingIdentityWithConstraint({ value: 10 }); // Lỗi compile-time tương tự

Giải thích:

  • interface Lengthwise định nghĩa yêu cầu tối thiểu đối với các kiểu dữ liệu.
  • function loggingIdentityWithConstraint<T extends Lengthwise>(arg: T): T sử dụng extends Lengthwise. Điều này có nghĩa là kiểu T phải là một kiểu nào đó mà có ít nhất các thuộc tính được định nghĩa trong Lengthwise. Nói cách khác, T phải có một thuộc tính length kiểu number.
  • Bên trong hàm, TypeScript biết chắc rằng arg có thuộc tính length, nên việc truy cập arg.length là an toàn.
  • Khi gọi hàm, TypeScript kiểm tra xem đối số có thỏa mãn ràng buộc Lengthwise hay không. stringarray thỏa mãn, còn number hoặc object không có .length thì không.

Ràng buộc Generic giúp bạn làm việc an toàn với các thuộc tính của biến kiểu bên trong các hàm hoặc lớp Generic.

Ví dụ 7: Ràng buộc Generic trên Key của Object

Một trường hợp phổ biến khác là khi bạn muốn truy cập một thuộc tính của một đối tượng, nhưng đối tượng và tên thuộc tính đều là Generic.

// Hàm lấy giá trị của một thuộc tính từ một đối tượng
// O là kiểu của đối tượng, K là kiểu của key (tên thuộc tính)
function getProperty<O, K extends keyof O>(obj: O, key: K): O[K] {
  return obj[key];
}

let person = { name: "Alice", age: 30, city: "New York" };

// Sử dụng getProperty:

// Lấy thuộc tính 'name' (string)
let personName = getProperty(person, "name"); // personName có kiểu 'string'
console.log(personName.toUpperCase()); // OK

// Lấy thuộc tính 'age' (number)
let personAge = getProperty(person, "age"); // personAge có kiểu 'number'
console.log(personAge.toFixed(2)); // OK

// Cố gắng lấy thuộc tính không tồn tại
// let personAddress = getProperty(person, "address"); // Lỗi compile-time: Argument of type '"address"' is not assignable to parameter of type '"name" | "age" | "city"'. Tuyệt vời!

Giải thích:

  • function getProperty<O, K extends keyof O>(obj: O, key: K): O[K] định nghĩa hai biến kiểu: O cho kiểu của đối tượng và K cho kiểu của khóa (key).
  • K extends keyof O là một ràng buộc rất mạnh mẽ. keyof O tạo ra một union type (kiểu kết hợp) của tất cả các tên thuộc tính hợp lệ của kiểu O (ví dụ: "name" | "age" | "city"). Ràng buộc này yêu cầu rằng K phải là một trong các khóa hợp lệ của O.
  • Kiểu trả về O[K] là một indexed access type (kiểu truy cập chỉ mục). Nó có nghĩa là "kiểu của thuộc tính K trong đối tượng kiểu O".
  • Với ràng buộc K extends keyof O, TypeScript biết rằng obj[key] luôn là một truy cập hợp lệ, và nó có thể xác định chính xác kiểu trả về là O[K].

Đây là một ví dụ nâng cao hơn về cách Genericsràng buộc có thể kết hợp để tạo ra các hàm tiện ích cực kỳ linh hoạt và an toàn kiểu.

5. Generics với nhiều biến kiểu

Bạn có thể sử dụng nhiều biến kiểu Generic khi cần thiết.

Ví dụ 8: Hàm pair với hai biến kiểu

Hàm này tạo ra một cặp (tuple) từ hai giá trị có thể thuộc các kiểu khác nhau.

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

let myTuple1 = pair("hello", 123); // TypeScript suy luận T là 'string', U là 'number'. myTuple1 có kiểu '[string, number]'
console.log(myTuple1); // Output: [ 'hello', 123 ]

let myTuple2 = pair(true, { status: "done" }); // TypeScript suy luận T là 'boolean', U là '{ status: string }'. myTuple2 có kiểu '[boolean, { status: string }]'
console.log(myTuple2); // Output: [ true, { status: 'done' } ]

// Bạn có thể truy cập các phần tử với type safety:
console.log(myTuple1[0].toUpperCase()); // OK, vì myTuple1[0] là string
console.log(myTuple1[1].toFixed(2)); // OK, vì myTuple1[1] là number

// console.log(myTuple1[0].toFixed()); // Lỗi compile-time! Property 'toFixed' does not exist on type 'string'.

Giải thích:

  • function pair<T, U>(first: T, second: U): [T, U] khai báo hai biến kiểu TU.
  • Đối số first có kiểu T, đối số second có kiểu U.
  • Kiểu trả về [T, U] là một tuple type, nghĩa là một mảng có số phần tử cố định, với phần tử đầu tiên có kiểu T và phần tử thứ hai có kiểu U.
  • TypeScript tự động suy luận kiểu cho TU dựa trên kiểu của các đối số truyền vào khi gọi hàm.

Sử dụng nhiều biến kiểu cho phép bạn mô tả mối quan hệ kiểu phức tạp hơn giữa các phần khác nhau của hàm, interface hoặc lớp.

Comments

There are no comments at the moment.