Bài 13.1: Generic functions và classes trong TypeScript

Chào mừng bạn đến với bài viết hôm nay, nơi chúng ta sẽ khám phá một trong những tính năng mạnh mẽlinh hoạt nhất của TypeScript: Generic. Nếu bạn từng thấy mình viết đi viết lại cùng một logic chỉ vì nó xử lý các kiểu dữ liệu (types) khác nhau, hoặc bạn cảm thấy khó chịu khi phải sử dụng any và mất đi sự an toàn kiểu, thì Generic chính là vị cứu tinh của bạn!

Generic Là Gì? Tại Sao Chúng Ta Cần Nó?

Hãy tưởng tượng bạn đang viết một hàm nhận vào một mảng và trả về phần tử đầu tiên của mảng đó.

Nếu bạn chỉ làm việc với mảng số, bạn có thể viết:

function getFirstNumber(arr: number[]): number | undefined {
  return arr.length > 0 ? arr[0] : undefined;
}

let firstNum = getFirstNumber([10, 20, 30]); // firstNum là number

Tuyệt vời. Nhưng bây giờ, bạn cũng cần một hàm tương tự cho mảng chuỗi:

function getFirstString(arr: string[]): string | undefined {
  return arr.length > 0 ? arr[0] : undefined;
}

let firstStr = getFirstString(["apple", "banana"]); // firstStr là string

Logic hàm là hoàn toàn giống nhau, nhưng bạn phải tạo hai hàm riêng biệt chỉ vì kiểu dữ liệu khác nhau. Điều này không hiệu quảkhó bảo trì.

Bạn có thể nghĩ đến việc sử dụng any:

function getFirstAny(arr: any[]): any {
  return arr.length > 0 ? arr[0] : undefined;
}

let value1 = getFirstAny([10, 20]); // value1 là any
let value2 = getFirstAny(["hello", "world"]); // value2 là any

value1.toFixed(2); // OK (runtime error nếu value1 không phải number)
value2.toUpperCase(); // OK (runtime error nếu value2 không phải string)

Sử dụng any cho phép hàm hoạt động với mọi kiểu dữ liệu, nhưng nó lại đánh mất toàn bộ thông tin kiểu. TypeScript không còn biết value1number hay value2string tại thời điểm biên dịch. Điều này làm giảm đáng kể khả năng bắt lỗi sớm của TypeScript.

Generic giải quyết vấn đề này bằng cách cho phép bạn viết các hàm, lớp hoặc cấu trúc dữ liệu hoạt động với nhiều kiểu dữ liệu khác nhau mà vẫn duy trì thông tin kiểu đó. Nó giống như việc sử dụng các biến cho kiểu dữ liệu!

Generic Functions (Hàm Generic)

Hàm generic sử dụng các tham số kiểu (type parameters), thường được biểu diễn bằng các chữ cái đơn như T, U, V, hoặc các tên gợi nhớ hơn. Quy ước phổ biến nhất là dùng T (viết tắt của Type).

Để khai báo một hàm generic, bạn đặt tham số kiểu trong dấu ngoặc nhọn <> ngay sau tên hàm:

function getFirstElement<T>(arr: T[]): T | undefined {
  return arr.length > 0 ? arr[0] : undefined;
}

Trong ví dụ này:

  • <T>: Chúng ta khai báo T là một tham số kiểu. Nó là một placeholder cho kiểu dữ liệu mà hàm này sẽ làm việc cùng.
  • arr: T[]: Tham số arr là một mảng chứa các phần tử có kiểu là T.
  • : T | undefined: Giá trị trả về của hàm sẽ có kiểu là T hoặc undefined.

Bây giờ, hãy xem cách sử dụng nó:

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

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

let booleans = [true, false];
let firstBoolean = getFirstElement(booleans); // TypeScript suy luận T là boolean
console.log(firstBoolean); // Output: true (firstBoolean là boolean | undefined)

Thấy không? Chỉ với một hàm duy nhất, chúng ta có thể làm việc với mảng number, string, boolean hoặc bất kỳ kiểu dữ liệu nào khác, và quan trọng nhất là TypeScript vẫn biết chính xác kiểu dữ liệu của giá trị trả về.

Bạn cũng có thể chỉ định rõ ràng tham số kiểu nếu muốn (mặc dù thường không cần thiết vì TypeScript đủ thông minh để suy luận):

let explicitFirstNum = getFirstElement<number>([100, 200]); // Chỉ định T là number
let explicitFirstStr = getFirstElement<string>(["one", "two"]); // Chỉ định T là string

// Nếu bạn chỉ định sai, TypeScript sẽ báo lỗi!
// let wrongType = getFirstElement<number>(["oops", "error"]); // Lỗi: Type 'string' is not assignable to type 'number'.
Hàm Generic với Nhiều Tham số Kiểu

Một hàm generic có thể có nhiều hơn một tham số kiểu. Ví dụ, một hàm tạo cặp giá trị:

function createPair<T, U>(v1: T, v2: U): [T, U] {
  return [v1, v2];
}

let pair = createPair("TypeScript", 4.5); // pair là [string, number]

console.log(pair[0].toUpperCase()); // OK, vì pair[0] là string
console.log(pair[1].toFixed(1));    // OK, vì pair[1] là number

// console.log(pair[0].toFixed(2)); // Lỗi biên dịch! pair[0] là string, không có toFixed.

Ở đây, T là kiểu của v1U là kiểu của v2. Kiểu trả về là một tuple [T, U], đảm bảo an toàn kiểu cho từng phần tử trong cặp.

Ràng buộc (Constraints) trong Generic Functions

Đôi khi, bạn muốn hàm generic của mình làm việc với mọi kiểu dữ liệu, nhưng chỉ khi kiểu dữ liệu đó có một số thuộc tính nhất định. Ví dụ, một hàm in ra độ dài của đối số. String và array có thuộc tính .length, nhưng number hoặc boolean thì không.

Bạn có thể sử dụng ràng buộc (constraints) với từ khóa extends.

Đầu tiên, định nghĩa một interface mô tả thuộc tính bạn cần:

interface Lengthy {
  length: number;
}

Bây giờ, áp dụng ràng buộc cho tham số kiểu T:

function logLength<T extends Lengthy>(arg: T): T {
  console.log(arg.length); // An toàn để truy cập .length vì T phải extends Lengthy
  return arg;
}

logLength("Hello World");    // OK, "Hello World" có length
logLength([1, 2, 3, 4, 5]);  // OK, [1, 2, 3, 4, 5] có length
// logLength(100);           // Lỗi biên dịch! Kiểu 'number' không thỏa mãn ràng buộc 'Lengthy'.
// logLength(true);          // Lỗi biên dịch! Kiểu 'boolean' không thỏa mãn ràng buộc 'Lengthy'.

Ràng buộc T extends Lengthy nghĩa là kiểu T phải là một kiểu con của Lengthy, hay nói cách khác, T phải có ít nhất thuộc tính length kiểu number. Điều này cho phép bạn sử dụng .length bên trong hàm một cách an toàn và đảm bảo người dùng chỉ truyền vào các đối số hợp lệ.

Generic Classes (Lớp Generic)

Tương tự như hàm, bạn cũng có thể tạo các lớp generic. Lớp generic rất hữu ích khi bạn xây dựng các cấu trúc dữ liệu như Stack, Queue, Tree, hoặc đơn giản là các lớp container chứa dữ liệu mà kiểu dữ liệu của chúng có thể khác nhau.

Để khai báo một lớp generic, đặt tham số kiểu sau tên lớp:

class DataStorage<T> {
  private data: T[] = []; // Mảng chứa dữ liệu có kiểu T

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

  getItems(): T[] {
    return [...this.data]; // Trả về bản sao để tránh thay đổi trực tiếp
  }

  removeItem(item: T): void {
    // Tìm index của item. Cần cẩn thận với việc so sánh object
    const index = this.data.indexOf(item);
    if (index > -1) {
      this.data.splice(index, 1);
    }
  }
}

Trong lớp DataStorage<T>:

  • <T>: Khai báo T là tham số kiểu cho lớp.
  • private data: T[]: Thuộc tính data là một mảng các phần tử kiểu T.
  • addItem(item: T): Phương thức nhận vào một item kiểu T.
  • getItems(): T[]: Phương thức trả về một mảng kiểu T.

Khi bạn tạo một thể hiện (instance) của lớp generic, bạn chỉ định hoặc TypeScript suy luận kiểu cho T:

// Tạo một storage chỉ cho số
let numberStorage = new DataStorage<number>();
numberStorage.addItem(10);
numberStorage.addItem(20);
// numberStorage.addItem("hello"); // Lỗi biên dịch! Kiểu 'string' không thể gán cho kiểu 'number'.

let numbersStored = numberStorage.getItems(); // numbersStored là number[]
console.log(numbersStored); // Output: [10, 20]

// Tạo một storage chỉ cho chuỗi
let stringStorage = new DataStorage<string>();
stringStorage.addItem("Apple");
stringStorage.addItem("Banana");

let stringsStored = stringStorage.getItems(); // stringsStored là string[]
console.log(stringsStored); // Output: ["Apple", "Banana"]

Lớp DataStorage linh hoạt, có thể lưu trữ bất kỳ kiểu dữ liệu nào, nhưng vẫn cung cấp sự an toàn kiểu mạnh mẽ.

Lớp Generic với Ràng buộc

Tương tự như hàm, lớp generic cũng có thể có ràng buộc. Ví dụ, nếu lớp của bạn cần làm việc với các đối tượng có thuộc tính id:

interface Identifiable {
  id: number | string;
}

class Repository<T extends Identifiable> {
  private items: T[] = [];

  add(item: T): void {
    // Có thể truy cập item.id một cách an toàn
    console.log(`Adding item with ID: ${item.id}`);
    this.items.push(item);
  }

  findById(id: number | string): T | undefined {
    return this.items.find(item => item.id === id);
  }

  // ... các phương thức khác sử dụng thuộc tính id
}

interface User extends Identifiable { name: string; }
interface Product extends Identifiable { price: number; }

let userRepository = new Repository<User>();
userRepository.add({ id: 1, name: "Alice" });
// userRepository.add({ name: "Bob" }); // Lỗi biên dịch! Thuộc tính 'id' bị thiếu trong kiểu '{ name: string; }'.

let productRepository = new Repository<Product>();
productRepository.add({ id: "prod-abc", price: 99.99 });

Ở đây, Repository<T extends Identifiable> đảm bảo rằng bất kỳ kiểu dữ liệu nào được sử dụng với Repository phải có thuộc tính id. Điều này cho phép các phương thức bên trong Repository (như add hoặc findById) dựa vào sự tồn tại của thuộc tính id một cách an toàn.

Generics Có Mặt Khắp Nơi!

Một khi bạn đã hiểu về Generics, bạn sẽ nhận ra chúng được sử dụng rất nhiều trong chính thư viện chuẩn của TypeScript và JavaScript (khi được định nghĩa trong file .d.ts).

Ví dụ:

  • Mảng: Array<number>, Array<string>
  • Promise: Promise<string> (Promise sẽ resolve thành một string)
  • Map: Map<string, number> (Map với key là string, value là number)
  • Set: Set<boolean> (Set chỉ chứa boolean)
  • ReadonlyArray<T>
  • Partial<T>, Pick<T, K>, Omit<T, K> (Utility Types - chúng ta sẽ nói đến sau!)

Việc hiểu Generic không chỉ giúp bạn viết code linh hoạt và an toàn hơn mà còn giúp bạn đọc và hiểu code của người khác, đặc biệt là các thư viện phổ biến, một cách dễ dàng hơn rất nhiều.

Comments

There are no comments at the moment.