Khi nào thì sử dụng Generics trong TypeScript

Trong TypeScript, có một kiểu dữ liệu khá đặc biệt, đó là kiểu generics. Với generic, chúng ta có thể viết ra các hàm / components / modules mà không nhất thiết phải chỉ định rõ kiểu dữ liệu lúc định nghĩa. Khai báo kiểu dữ liệu sẽ được đẩy về lúc sử dụng components đó. Như vậy, lúc sử dụng, nếu chúng ta chỉ định kiểu dữ liệu là number thì component sẽ chỉ chấp nhận dữ liệu kiểu number, tương tự như vậy, chúng ta có thể chỉ định kiểu dữ liệu cụ thể bất kì lúc sử dụng.

Với sự tiện dụng đó, bạn có thể thắc mắc, tại sao chúng ta không luôn sử dụng kiểu generics? Khi nào chúng ta nên dùng generics, khi nào nên chỉ định rõ kiểu dữ liệu lúc định nghĩa components? …

Giả định một tình huống cụ thể

Chương trình của chúng ta có 1 hàm trả về đối tượng “già nhất” trong 1 tập dữ liệu:

function getOldest(items: Array<{ age: number }>) {
    return items.sort((a, b) => b.age - a.age)[0];
}

Có thể thấy, hàm này chấp nhận kiểu dữ liệu đầu vào là 1 object bất kì, với điều kiện là nó phải có thuộc tính age. Để rõ ràng hơn, ta sẽ định nghĩa kiểu dữ liệu cho ràng buộc này như sau:

type HasAge = { age: number };

Như vậy, hàm của chúng ta sẽ được viết lại rõ ràng hơn như sau:

function getOldest(items: HasAge[]): HasAge {
    return items.sort((a, b) => b.age - a.age)[0];
}

Với ngữ cảnh này, chúng ta chạy một đoạn mã kiểm tra có sử dụng hàm trên như sau:

const things = [{ age: 10 }, { age: 20 }, { age: 15 }];
const oldestThing = getOldest(things);

console.log(oldestThing.age); // 20

Có thể thấy, vì dữ liệu trả về từ hàm getOldest() là một đối tượng có “hình dạng” tuân theo kiểu HasAge, do đó nó sẽ có thuộc tính age, vì thế ta có thể in ra độ tuổi của nó bằng cách gọi oldestThing.age. Cho tới lúc này đoạn mã của chúng ta viết vẫn đáp ứng tốt yêu cầu.

Vấn đề xảy ra khi chúng ta sử dụng kiểu dữ liệu phức tạp hơn

Giả sử, chúng ta có kiểu dữ liệu là Person, và tất nhiên, người thì có thuộc tính age, vì dữ liệu kiểu Person bao hàm luôn kiểu dữ liệu HasAge, nên ta có thể sử dụng được hàm getOldest() đối với dữ liệu kiểu Person.

type Person = { name: string, age: number};

const people: Person[] = [
    { name: 'Amir', age: 10 }, 
    { name: 'Betty', age: 20 }, 
    { name: 'Cecile', age: 15 }
];

const oldestPerson = getOldest(people); // This is OK

Vậy vấn đề ở đâu? Vấn đề là: kể cả khi ta sử dụng hàm getOldest với một kiểu dữ liệu khác (lớn hơn), dữ liệu trả về chỉ có kiểu dữ liệu là HasAge.

Chính vì kiểu dữ liệu trả về có kiểu là HasAge (chứ không phải kiểu Person), nên đoạn code sau sẽ báo lỗi khi ta truy cập vào thuộc tính name:

console.log(oldestPerson.name); // type error: Property 'name' does not exist on type 'HasAge'.

Rõ ràng, khi ta truyền vào mảng people có kiểu dữ liệu là Person, đối tượng trả về sẽ có thuộc tính name. Tuy nhiên, vì ta đã “hard-code” kiểu dữ liệu trả về của ta là HasAge, vậy nên trình thông dịch của TypeScript sẽ không chấp nhận cho ta truy cập bất kì thuộc tính nào khác ngoài age. Chương trình của ta đã mất đi tính linh động.

Kể cả khi ta cố tình ép kiểu, TypeScript vẫn sẽ báo lỗi:

const oldestPerson: Person = getOldest(people); // type error
// Property 'name' is missing in type 'HasAge' but required in type 'Person'.

Kể cả khi ta có thể “lách luật” trình biên dịch với type assertions, thì đó cũng không phải là 1 cách làm tốt.

const oldestPerson = getOldest(people) as Person; // Lách luật
console.log(oldestPerson.name); // no type error

(Thử đoạn mã trên với TypeScript Playground)

Khi Generics phát huy tác dụng

Vì chương trình của ta đã bị mất đi tính linh động, ta có thể dùng generics để cải tiến như sau:

function getOldest<T extends HasAge>(items: T[]): T {
    return items.sort((a, b) => b.age - a.age)[0];
}

const oldestPerson = getOldest(people); // type Person

Done! Với việc sử dụng kiểu dữ liệu linh động, giờ ta biến oldestPerson của chúng ta đã kiểu dữ liệu là Person, và kiểu dữ liệu này linh động phù hợp đúng theo kiểu dữ liệu đã truyền vào.

Như vậy, ta đã có thể truy cập được thuộc tính name của dữ liệu trả về mà không bị TypeScript báo lỗi nữa:

const oldestPerson = getOldest(people); // type Person
console.log(oldestPerson.name);  // OK

Ta có thể kiểm tra với nhiều kiểu dữ liệu khác ở đây:

type Person = {name: string, age: number};
const people: Person[] = [
    { name: 'Amir', age: 10 }, 
    { name: 'Betty', age: 20 }, 
    { name: 'Cecile', age: 15 }
 ];

type Bridge = {name: string, length: number, age: number};
const bridges = [
    { name: 'London Bridge', length: 269, age: 48 },
    { name: 'Tower Bridge', length: 244, age: 125 },
    { name: 'Westminster Bridge', length: 250, age: 269 }
]

const oldestPerson = getOldest(people); // type Person
const oldestBridge = getOldest(bridges); // type Bridge

console.log(oldestPerson.name); // 'Betty' 
console.log(oldestBridge.length); // '250' 

(Thử đoạn mã trên với TypeScript Playground ở đây)

Khi nào không nên dùng Generics?

Kể cả khi bạn sử dụng dữ liệu đầu vào có kiểu là HasAge hay là một dữ liệu phủ lớn hơn của nó, nếu dữ liệu trả về của bạn không cần linh hoạt, hoặc không liên quan gì tới kiểu dữ liệu truyền vào, lúc đó bạn không cần sử dụng Generics làm gì cả. Ví dụ: trường hợp này sử dụng generics là không cần thiết.

function isFirstOlder<T extends HasAge>(a: T, b: T) {
    return a.age > b.age;
}

Có thể thấy, generics không phát huy được ưu điểm của nó trong tình huống này. Với xử lí ở trên, đơn giản ta chỉ cần làm như sau:

function isFirstOlder(a: HasAge, b: HasAge) {
    return a.age > b.age;
}

Tham khảo

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s