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

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ì và í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ểuT
phải lànumber
. Do đó, chữ ký hàm trở thànhidentityTS(arg: number): number
. - Khi gọi
identityTS("hello")
, TypeScript suy luậnT
làstring
, và chữ ký trở thànhidentityTS(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ểuT
.- Kiểu trả về
T | undefined
nghĩa là hàm sẽ trả về một phần tử có kiểuT
hoặcundefined
nếu mảng rỗng. - Khi gọi
getFirstElement(numbers)
,numbers
lànumber[]
, nên TypeScript suy luậnT
lànumber
. - Khi gọi
getFirstElement(strings)
,strings
làstring[]
, nênT
đượ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ểuT
.- 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ể choT
bên trong dấu ngoặc nhọn<>
. Ví dụ:Box<number>
hoặcBox<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ểuT
cho thuộc tínhitem
. Các thuộc tính khác nhưid
vàdescription
có kiểu cố định (number
,string
).- Khi sử dụng
Container
, bạn chỉ định kiểu củaitem
. Ví dụ:Container<{ name: string, price: number }>
hoặcContainer<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ểuT
.- Thuộc tính
data
được định nghĩa là một mảngT[]
. - Các phương thức
addItem
,removeItem
,getItems
đều sử dụng biến kiểuT
để đả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ụngextends Lengthwise
. Điều này có nghĩa là kiểuT
phải là một kiểu nào đó mà có ít nhất các thuộc tính được định nghĩa trongLengthwise
. Nói cách khác,T
phải có một thuộc tínhlength
kiểunumber
.- Bên trong hàm, TypeScript biết chắc rằng
arg
có thuộc tínhlength
, nên việc truy cậparg.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.string
vàarray
thỏa mãn, cònnumber
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ểuO
(ví dụ:"name" | "age" | "city"
). Ràng buộc này yêu cầu rằngK
phải là một trong các khóa hợp lệ củaO
.- 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ínhK
trong đối tượng kiểuO
". - Với ràng buộc
K extends keyof O
, TypeScript biết rằngobj[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 Generics và rà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ểuT
vàU
.- Đối số
first
có kiểuT
, đối sốsecond
có kiểuU
. - 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ểuT
và phần tử thứ hai có kiểuU
. - TypeScript tự động suy luận kiểu cho
T
vàU
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