NỘI DUNG BÀI HỌC
🔗 Hướng dẫn TypeScript Generics
📊 Đọc file XLSX trong Node.js bằng async/await
⚙️ Xử lý file JSON với TypeScript và fs/promises
📖 Cách dùng Record trong TypeScript để quản lý dữ liệu
Phần 1: Vấn đề "nan giải" khi chưa có Generics
Trước khi đi vào "Generic là gì?", chúng ta hãy cùng xem một vấn đề mà mọi lập trình viên đều gặp phải: Sự lặp lại code và sự thiếu an toàn về kiểu.
Hãy tưởng tượng bạn cần viết một hàm đơn giản, nhiệm vụ của nó là nhận vào một đối số và trả về chính đối số đó.
Cách tiếp cận 1: Dùng kiểu cụ thể
function traVeSo(arg: number): number {
return arg;
}
function traVeChuoi(arg: string): string {
return arg;
}
// Rất nhiều hàm khác cho boolean, object, array...
Vấn đề: Quá nhiều hàm lặp đi lặp lại. Nếu có 100 kiểu dữ liệu, chẳng lẽ chúng ta phải viết 100 hàm? Rất thiếu hiệu quả.
Cách tiếp cận 2: Dùng kiểu any
Tuyệt vời! any có vẻ là một giải pháp, nó chấp nhận mọi kiểu dữ liệu.
function traVeGiaTriBatKy(arg: any): any {
return arg;
}
let output = traVeGiaTriBatKy("Xin chào");
// Vấn đề ở đây! TypeScript không biết `output` là một chuỗi.
// Bạn có thể gọi `output.toFixed(2)` mà không bị báo lỗi lúc code,
// và chương trình sẽ "toang" khi chạy.
console.log(output.length); // Hoạt động, nhưng là do may mắn.
Vấn đề: Chúng ta đã đánh mất "linh hồn" của TypeScript - đó là Type Safety (An toàn kiểu). Trình biên dịch không còn biết kiểu dữ liệu là gì, dẫn đến nguy cơ xảy ra lỗi lúc runtime.
Đây chính là lúc Generic xuất hiện như một người hùng!
Phần 2: Generic là gì?
Ví von cốt lõi: Hãy tưởng tượng Generic Type như một cái chai nước rỗng.
Cái chai này bản thân nó không quy định chứa loại nước gì. Nó chỉ là một cái "khuôn" hay một "vật chứa".
Khi bạn đổ nước khoáng vào, nó trở thành một chai nước khoáng.
Khi bạn đổ nước cam vào, nó trở thành một chai nước cam.
Khi bạn đổ trà sữa vào, nó trở thành một chai trà sữa.
Cái chai (hàm/class/interface) vẫn giữ nguyên hình dáng và công dụng của nó, nhưng loại "nước" (kiểu dữ liệu) bên trong có thể thay đổi. Quan trọng nhất là, một khi bạn đã đổ nước cam vào, bạn biết chắc chắn rằng thứ bạn lấy ra từ chai đó phải là nước cam, chứ không thể là nước khoáng được. Đây chính là Type Safety.
Trong TypeScript, "cái chai" này được ký hiệu bằng dấu ngoặc nhọn <T>. T ở đây là viết tắt của Type, nhưng bạn có thể dùng bất kỳ chữ cái nào khác (U, K, V, TItem...). Nó là một biến đại diện cho một kiểu dữ liệu (type variable) sẽ được cung cấp sau.
Phần 3: Đi sâu vào Cú pháp (Syntax) của Generic
Để hiểu rõ cách Generic hoạt động, điều quan trọng là phải nắm vững cú pháp của nó. Cú pháp này có vẻ hơi lạ lúc đầu, nhưng nó rất nhất quán khi áp dụng ở các ngữ cảnh khác nhau (hàm, class, interface), giúp bạn dễ dàng làm chủ một khi đã hiểu rõ quy tắc.
"Biến Kiểu" (Type Variable) và Dấu ngoặc nhọn <>
Thành phần cốt lõi của Generic là biến kiểu, thường được đặt trong cặp dấu ngoặc nhọn <>.
<T>
< >: Cặp dấu ngoặc này báo hiệu cho TypeScript rằng chúng ta đang khai báo một hoặc nhiều "biến" cho kiểu dữ liệu. Nó có vai trò tương tự như cặp dấu () để khai báo tham số (giá trị) cho hàm vậy.
T: Đây là tên của biến kiểu. Nó là một placeholder (trình giữ chỗ) cho một kiểu dữ liệu cụ thể sẽ được cung cấp sau này khi hàm, class, hoặc interface được sử dụng.
Quy ước đặt tên:
T là phổ biến nhất, là viết tắt của Type.
Khi cần nhiều biến kiểu, người ta thường dùng các chữ cái tiếp theo: U, V.
Sử dụng các tên mang tính mô tả để code dễ đọc hơn:
K cho key: function getProperty<T, K extends keyof T>(obj: T, key: K)
V cho value: class Dictionary<K, V> { /*...*/ }
E cho element: function printArray<E>(arr: E[])
Bạn có thể đặt bất kỳ tên hợp lệ nào, nhưng tuân theo quy ước sẽ giúp người khác đọc code của bạn dễ dàng hơn.
Cú pháp trong từng ngữ cảnh
Trong Hàm (Functions)
Phần khai báo biến kiểu <T> được đặt ngay sau tên hàm và trước danh sách tham số (). Đây là vị trí cố định và quan trọng nhất để TypeScript hiểu rằng bạn đang định nghĩa một hàm generic.
Cấu trúc tổng quát:
function tenHam<T, U, ...>(thamSo1: T, thamSo2: U, ...): KieuTraVe {
// ... thân hàm ...
}
Ví dụ thực tế:
Hãy viết một hàm nhận vào hai đối số có thể thuộc bất kỳ kiểu nào và trả về một object chứa cả hai giá trị đó.
function taoCapGiaTri<T, U>(key: T, value: U): { key: T; value: U } {
return { key: key, value: value };
}
// Cách sử dụng
let cap1 = taoCapGiaTri<string, number>("tuoi", 30);
console.log(`Key: ${cap1.key}, Value: ${cap1.value}`);
Phân tích chi tiết cú pháp:
taoCapGiaTri: Tên của hàm.
<T, U>: Phần khai báo biến kiểu. Chúng ta báo cho TypeScript: "Hàm này sẽ làm việc với hai kiểu dữ liệu độc lập mà tôi sẽ đặt tên tạm là T và U".
(key: T, value: U): Danh sách tham số, nơi chúng ta sử dụng T và U để định kiểu cho các tham số đầu vào.
: { key: T; value: U }: Kiểu dữ liệu trả về của hàm. Chúng ta sử dụng lại T và U để định nghĩa cấu trúc của object trả về.
Trong Interfaces và Type Aliases
Phần khai báo <T> được đặt ngay sau tên của interface/type.
Cấu trúc tổng quát:
interface TenInterface<T> {
propertyName: T;
}
type TenType<T> = {
propertyName: T;
};
Ví dụ thực tế:
interface Result<TData> {
isSuccess: boolean;
error?: string;
data: TData;
}
// Khi sử dụng
let userResult: Result<string> = { isSuccess: true, data: "Lấy dữ liệu thành công" };
let productResult: Result<{ id: number, name: string }> = {
isSuccess: true,
data: { id: 1, name: "Laptop" }
};
Phân tích chi tiết cú pháp:
interface Result<TData>: Chúng ta định nghĩa một interface tên là Result có một biến kiểu là TData.
data: TData;: Bên trong interface, chúng ta dùng TData làm kiểu cho thuộc tính data.
Khi sử dụng let userResult: Result<string>, chúng ta đã "cung cấp" kiểu string cho placeholder TData. Lúc này, TypeScript hiểu rằng thuộc tính data của userResult phải là một chuỗi.
Trong Lớp (Classes)
Tương tự như interface, phần khai báo <T> được đặt ngay sau tên lớp.
Cấu trúc tổng quát:
class TenLop<T> {
constructor(value: T) { /* ... */ }
myMethod(value: T): T { /* ... */ }
}
Ví dụ thực tế:
class DataStorage<T> {
private data: T;
constructor(initialData: T) {
this.data = initialData;
}
getData(): T {
return this.data;
}
}
// Khi sử dụng
const stringStorage = new DataStorage<string>("Hello Generics");
console.log(stringStorage.getData().toUpperCase());
Phân tích chi tiết cú pháp:
class DataStorage<T>: Định nghĩa lớp DataStorage với một biến kiểu T.
Biến kiểu T này có thể được sử dụng ở mọi nơi trong lớp: cho thuộc tính (private data: T), cho tham số của constructor (initialData: T), và cho kiểu trả về của phương thức (getData(): T).
Cú pháp cho Ràng buộc (Constraints)
Để thêm ràng buộc, chúng ta dùng từ khóa extends bên trong dấu ngoặc nhọn <>.
Cấu trúc tổng quát:
function tenHam <T extends KieuRangBuoc> (arg: T) { /* ... */ }
Ví dụ thực tế:
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): void {
console.log(arg.length);
}
logLength("hello"); // OK, string có thuộc tính .length
logLength([1, 2, 3]); // OK, array có thuộc tính .length
// logLength(123); // Lỗi! number không có thuộc tính .length
Phân tích chi tiết cú pháp:
<T extends Lengthwise>: Phần này có nghĩa là "Khai báo một biến kiểu T, với điều kiện T phải tương thích hoặc kế thừa từ interface Lengthwise".
Điều này đảm bảo rằng bất kỳ giá trị nào có kiểu T được truyền vào hàm logLength chắc chắn sẽ có thuộc tính .length, giúp cho dòng code console.log(arg.length) luôn an toàn về kiểu khi biên dịch.
Phần 4: Generic trong thực tế - Code thôi!
Generic Functions (Hàm Generic)
Bây giờ, hãy viết lại hàm traVeGiaTri của chúng ta bằng Generic.
// Ở đây, <T> chính là "cái chai rỗng" của chúng ta.
// Hàm này nhận vào một `arg` có kiểu `T` và trả về một giá trị cũng có kiểu `T`.
function identity<T>(arg: T): T {
return arg;
}
// 1. Đổ "nước khoáng" (string) vào chai
let outputString = identity<string>("Chào thế giới TypeScript!");
// TypeScript biết chắc chắn `outputString` là một chuỗi, nên gợi ý các phương thức của chuỗi.
console.log(outputString.toUpperCase());
// 2. Đổ "nước cam" (number) vào chai
let outputNumber = identity<number>(100);
// TypeScript biết chắc chắn `outputNumber` là một con số.
console.log(outputNumber.toFixed(2));
// 3. TypeScript rất thông minh, nó có thể tự "suy luận" kiểu (Type Inference)
// Bạn không cần ghi <boolean> tường minh, nó tự hiểu.
let outputBoolean = identity(true);
console.log(!outputBoolean);
Ví dụ thực tế hơn: Viết một hàm lấy phần tử đầu tiên của một mảng.
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
let numbers = [1, 2, 3];
let firstNum = getFirstElement(numbers); // firstNum được suy luận là kiểu `number`
let strings = ["a", "b", "c"];
let firstStr = getFirstElement(strings); // firstStr được suy luận là kiểu `string`
Hàm này hoạt động hoàn hảo với mảng số, mảng chuỗi, mảng object... mà không cần viết lại.
Generic Interfaces (Interface Generic)
Đây là một trong những ứng dụng phổ biến nhất! Hãy tưởng tượng bạn đang làm việc với API. Cấu trúc phản hồi (response) từ server thường giống nhau, chỉ có phần data là thay đổi.
// Định nghĩa một cấu trúc API response chung
interface ApiResponse<T> {
success: boolean;
message: string;
data: T; // Dữ liệu có thể là bất cứ thứ gì!
}
// Định nghĩa các model dữ liệu
interface User {
id: number;
name: string;
email: string;
}
interface Product {
sku: string;
name: string;
price: number;
}
// Sử dụng ApiResponse với User
const userResponse: ApiResponse<User> = {
success: true,
message: "Lấy thông tin người dùng thành công!",
data: {
id: 1,
name: "Gemini",
email: "gemini@google.com"
}
};
// Sử dụng ApiResponse với một mảng các Product
const productResponse: ApiResponse<Product[]> = {
success: true,
message: "Lấy danh sách sản phẩm thành công!",
data: [
{ sku: "TS-001", name: "Áo phông TypeScript", price: 250000 },
{ sku: "JS-002", name: "Cốc cà phê JavaScript", price: 150000 }
]
};
// Truy cập dữ liệu một cách an toàn
console.log(userResponse.data.name); // Gemini
console.log(productResponse.data[0].price); // 250000
Chúng ta đã tạo ra một "khuôn" ApiResponse có thể tái sử dụng cho mọi loại dữ liệu trả về từ API.
Generic Classes (Lớp Generic)
Tương tự, ta có thể tạo ra các lớp có thể làm việc với nhiều kiểu dữ liệu khác nhau.
Ví von: Hãy tưởng tượng một KhoHang (Nhà kho). Nhà kho này có thể chứa DienThoai (Điện thoại), hoặc Oto (Ô tô), hoặc Sach (Sách). Cấu trúc của nhà kho (thêm hàng, bớt hàng) là như nhau, chỉ có mặt hàng bên trong là khác nhau.
class KhoHang<T> {
private items: T[] = [];
themMatHang(item: T) {
this.items.push(item);
}
layMatHang(): T | undefined {
return this.items.pop();
}
}
// Một kho hàng chuyên chứa điện thoại
interface DienThoai {
model: string;
}
const khoDienThoai = new KhoHang<DienThoai>();
khoDienThoai.themMatHang({ model: "iPhone 17 Pro" });
const dt = khoDienThoai.layMatHang();
console.log(dt?.model); // TypeScript biết `dt` có thuộc tính `model`
// Một kho hàng chuyên chứa sách
interface Sach {
tieuDe: string;
}
const khoSach = new KhoHang<Sach>();
khoSach.themMatHang({ tieuDe: "Lập trình TypeScript toàn tập" });
const sach = khoSach.layMatHang();
console.log(sach?.tieuDe); // TypeScript biết `sach` có thuộc tính `tieuDe`
Nâng cao hơn - Generic Constraints (Ràng buộc Generic)
Đôi khi, chúng ta muốn "cái chai" của mình không phải là một chai rỗng hoàn toàn. Chúng ta muốn nó chỉ chấp nhận những loại "nước" có một đặc tính nhất định.
Ví von: Bạn có một cái máy ép hoa quả. Cái máy này (hàm generic) có thể ép được nhiều loại quả (kiểu dữ liệu), nhưng không phải thứ gì cũng ép được. Thứ bạn bỏ vào phải là "hoa quả", tức là nó phải có các đặc tính như ten và vi (tên và vị). Bạn không thể bỏ một cục đá vào máy ép được.
Trong TypeScript, chúng ta sử dụng từ khóa extends để tạo ra các ràng buộc này.
interface CoTheInTen {
name: string;
}
// Hàm này chấp nhận bất kỳ kiểu T nào,
// miễn là kiểu T đó phải có thuộc tính `name` kiểu `string`.
function inThongTin<T extends CoTheInTen>(item: T): void {
console.log(`Xin chào, tên tôi là ${item.name}`);
}
let nguoi = { name: "Gemini", age: 1 };
let xe = { name: "Ford Ranger", color: "Xám" };
let maytinh = { brand: "Dell", price: 2000 };
inThongTin(nguoi); // OK
inThongTin(xe); // OK
// inThongTin(maytinh); // Lỗi! TypeScript báo lỗi vì object `maytinh` không có thuộc tính `name`.
Generic Constraints giúp chúng ta vừa linh hoạt, vừa đảm bảo rằng kiểu dữ liệu được truyền vào phải có đủ các thuộc tính/phương thức cần thiết để hàm có thể hoạt động đúng.
Tổng kết lợi ích của Generic
Tái sử dụng code (Code Reusability): Viết một lần, dùng cho nhiều kiểu dữ liệu.
An toàn kiểu (Type Safety): Loại bỏ các lỗi tiềm ẩn do dùng any và giữ được sức mạnh của TypeScript.
Code sạch và dễ đọc hơn (Cleaner Code): Giảm thiểu sự lặp lại, giúp code base của bạn gọn gàng và dễ bảo trì hơn.
Generic là một trong những khái niệm nền tảng và mạnh mẽ nhất của TypeScript. Nắm vững nó sẽ giúp bạn viết code hiệu quả, an toàn và chuyên nghiệp hơn rất nhiều.
Phần 5: Đọc dữ liệu từ file.
Chúng ta sẽ tập trung vào hai định dạng file phổ biến nhất: JSON (rất phổ biến trong thế giới web) và XLSX (file Excel, "vua" trong môi trường doanh nghiệp). Chúng ta sẽ sử dụng phương pháp hiện đại với async/await để xử lý các tác vụ này một cách gọn gàng và hiệu quả.
Mở đầu: Tại sao phải là async/await?
Đọc file từ ổ đĩa là một thao tác I/O (Input/Output). Trong Node.js, các thao tác I/O là bất đồng bộ (asynchronous), nghĩa là chương trình sẽ không "ngồi chờ" cho đến khi đọc file xong mà sẽ tiếp tục chạy các tác vụ khác.
async/await là cú pháp "đường" (syntactic sugar) giúp chúng ta viết code bất đồng bộ trông giống như code đồng bộ tuần tự, khiến nó dễ đọc, dễ viết và dễ quản lý lỗi hơn rất nhiều so với callback hoặc Promise chain (.then()).
Đọc file JSON - Người bạn thân của JavaScript
Đọc file JSON tương đối đơn giản vì nó là định dạng văn bản (text) và có thể được chuyển đổi trực tiếp thành đối tượng JavaScript.
Các bước thực hiện:
Sử dụng module fs/promises của Node.js để đọc nội dung file.
Nội dung trả về là một Buffer, chúng ta cần chuyển nó thành chuỗi (string).
Sử dụng JSON.parse() để biến chuỗi JSON thành một đối tượng hoặc mảng JavaScript.
Giả sử chúng ta có một file tên là users.json với nội dung sau:
[
{
"id": 1,
"name": "An",
"role": "Admin"
},
{
"id": 2,
"name": "Binh",
"role": "Member"
}
]
Code đọc file:
import { readFile } from 'fs/promises'; // Sử dụng module fs/promises
import { resolve } from 'path';
/**
* Đọc và phân tích một file JSON.
* @param filePath Đường dẫn đến file JSON.
* @returns {Promise<T>} Một Promise chứa dữ liệu đã được phân tích.
*/
async function readJsonFile<T>(filePath: string): Promise<T> {
try {
console.log(`Đang đọc file JSON từ: ${filePath}`);
// 1. Đọc nội dung file dưới dạng Buffer
const fileBuffer = await readFile(filePath);
// 2. Chuyển Buffer thành chuỗi UTF-8
const jsonString = fileBuffer.toString('utf-8');
// 3. Phân tích chuỗi JSON thành đối tượng JavaScript
const data: T = JSON.parse(jsonString);
console.log("✅ Đọc file JSON thành công!");
return data;
} catch (error) {
console.error("❌ Đã xảy ra lỗi khi đọc file JSON:", error);
// Ném lại lỗi để bên gọi có thể xử lý
throw error;
}
}
// === Ví dụ sử dụng ===
interface User {
id: number;
name: string;
role: string;
}
async function main() {
// resolve('users.json') sẽ tạo ra đường dẫn tuyệt đối đến file
const users = await readJsonFile<User[]>(resolve('users.json'));
console.log("Dữ liệu người dùng:");
users.forEach(user => {
console.log(`- ${user.name} (Role: ${user.role})`);
});
}
main();
Đọc file XLSX (Excel) - Chinh phục dữ liệu bảng tính
File XLSX không phải là file văn bản thuần túy. Nó là một định dạng nén (zip) chứa nhiều file XML bên trong. Vì vậy, chúng ta không thể dùng fs để đọc trực tiếp mà cần một thư viện chuyên dụng. Thư viện phổ biến và mạnh mẽ nhất cho việc này là xlsx.
Bước 0: Cài đặt thư viện
Mở terminal và chạy lệnh:
npm install xlsx
Các bước thực hiện:
Import thư viện xlsx.
Sử dụng XLSX.readFile(filePath) để đọc toàn bộ file Excel. Thao tác này trả về một đối tượng "workbook".
Một workbook chứa nhiều "sheet". Lấy tên của sheet đầu tiên (hoặc sheet bạn muốn).
Lấy đối tượng "worksheet" từ workbook bằng tên của nó.
Sử dụng XLSX.utils.sheet_to_json(worksheet) để chuyển đổi dữ liệu trong sheet thành một mảng các đối tượng JavaScript. Đây là cách tiện lợi nhất!
Ví dụ:
Giả sử chúng ta có file products.xlsx với sheet đầu tiên có nội dung:
|
id |
productName |
price |
inStock |
|
101 |
Laptop Pro |
25000000 |
true |
|
102 |
Bàn phím cơ |
1500000 |
true |
|
103 |
Chuột không dây |
500000 |
false |
Code đọc file:
import * as XLSX from 'xlsx'; // Import thư viện
import { resolve } from 'path';
/**
* Đọc dữ liệu từ sheet đầu tiên của một file Excel.
* @param filePath Đường dẫn đến file XLSX.
* @returns {Promise<T[]>} Một Promise chứa một mảng các object từ Excel.
*/
async function readXlsxFile<T>(filePath: string): Promise<T[]> {
try {
console.log(`Đang đọc file XLSX từ: ${filePath}`);
// 1. Đọc toàn bộ file Excel
// Thư viện xlsx tự xử lý việc đọc file bất đồng bộ bên trong
const workbook = XLSX.readFile(filePath);
// 2. Lấy tên của sheet đầu tiên
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
throw new Error("File Excel không có sheet nào.");
}
console.log(`Đang xử lý sheet: ${firstSheetName}`);
// 3. Lấy đối tượng worksheet
const worksheet = workbook.Sheets[firstSheetName];
// 4. Chuyển đổi sheet thành mảng các đối tượng JSON
// Thư viện sẽ tự động lấy dòng đầu tiên làm key cho object
const data: T[] = XLSX.utils.sheet_to_json<T>(worksheet)
console.log("✅ Đọc file XLSX thành công!");
return data;
} catch (error) {
console.error("❌ Đã xảy ra lỗi khi đọc file XLSX:", error);
throw error;
}
}
// === Ví dụ sử dụng ===
interface Product {
id: number;
productName: string;
price: number;
inStock: boolean;
}
async function mainExcel() {
const products = await readXlsxFile<Product>(resolve('products.xlsx'));
console.log("Dữ liệu sản phẩm:");
products.forEach(product => {
const stockStatus = product.inStock ? "Còn hàng" : "Hết hàng";
console.log(`- ${product.productName} | Giá: ${product.price.toLocaleString()}đ | Trạng thái: ${stockStatus}`);
});
}
mainExcel();
Tổng kết và các điểm cần nhớ
Luôn dùng async/await và try...catch: Đây là cách tốt nhất để xử lý các tác vụ I/O và quản lý lỗi một cách sạch sẽ.
JSON là văn bản: Bạn cần đọc file -> chuyển sang string -> JSON.parse().
XLSX là file nhị phân: Cần dùng thư viện chuyên dụng như xlsx. Luồng xử lý là readFile -> lấy sheet -> sheet_to_json.
fs/promises: Luôn ưu tiên sử dụng module này thay vì fs cũ với callback để tận dụng sức mạnh của async/await.
XLSX.utils.sheet_to_json: Là "người hùng" giúp bạn chuyển đổi dữ liệu bảng tính thành cấu trúc dữ liệu quen thuộc và dễ sử dụng nhất trong JavaScript.
Chúc bạn áp dụng thành công vào các dự án của mình!
BONUS: "Utility Type
Vấn đề: Làm sao để định kiểu cho một đối tượng "động"?Hãy tưởng tượng bạn cần tạo một đối tượng để lưu trữ cấu hình, điểm số của người chơi, hoặc một danh sách sản phẩm được tra cứu bằng mã SKU. Đặc điểm chung của các đối tượng này là:
Bạn không biết trước tất cả các key (khóa).
Nhưng bạn biết chắc chắn rằng tất cả các key sẽ là string (hoặc number).
Và bạn cũng biết chắc chắn rằng tất cả các value (giá trị) sẽ có cùng một kiểu dữ liệu (ví dụ: boolean, number, hoặc một interface Product).
Nếu dùng cách thông thường, bạn có thể dùng any hoặc một interface rỗng {} nhưng sẽ làm mất đi sự an toàn về kiểu. Đây chính là lúc Record tỏa sáng.
Ví von thực tế: Hãy nghĩ về một cuốn từ điển. Bạn không thể biết trước tất cả các "từ" (keys) có trong đó, nhưng bạn biết chắc chắn 100% rằng:
Mỗi "từ" (key) là một chuỗi ký tự (string).
Mỗi "định nghĩa" (value) đi kèm cũng là một chuỗi ký tự (string).
Record<string, string> chính là cách để mô tả "hình dạng" của một cuốn từ điển trong TypeScript.
Cú pháp của Record<Keys, Type>
Record là một Utility Type nhận vào hai tham số:
type MyObject = Record<Keys, Type>;
Keys: Đại diện cho kiểu dữ liệu của các key. Kiểu này phải là string, number, symbol, hoặc một tập hợp các giá trị cụ thể (union type, ví dụ: 'admin' | 'user').
Type: Đại diện cho kiểu dữ liệu của các value. Đây có thể là bất kỳ kiểu nào: string, boolean, User, Product[], v.v.
Về cơ bản, Record<Keys, Type> tương đương với kiểu { [key in Keys]: Type }.
Các ví dụ thực tế
Ví dụ 1: Cấu hình bật/tắt tính năng (Feature Flags)
Đây là trường hợp sử dụng kinh điển. Bạn có một đối tượng để kiểm soát các tính năng của ứng dụng.
// Tất cả các key là string, tất cả value là boolean
const featureFlags: Record<string, boolean> = {
'darkMode': true,
'newHomePage': false,
'betaFeature': true
};
// Lợi ích:
// 1. An toàn khi gán giá trị:
// featureFlags.newHomePage = 'false'; // Lỗi! Type 'string' is not assignable to type 'boolean'.
// 2. An toàn khi truy cập:
// Bạn biết chắc giá trị trả về sẽ là boolean hoặc undefined (nếu key không tồn tại)
const isDarkMode = featureFlags['darkMode']; // isDarkMode có kiểu boolean
// 3. Dễ dàng mở rộng:
featureFlags['experimentalAnalytics'] = true; // OK!
Ví dụ 2: Gom nhóm dữ liệu - Phân loại sản phẩm
Giả sử bạn có một mảng các sản phẩm và bạn muốn sắp xếp chúng vào một đối tượng với key là categoryId và value là mảng các sản phẩm thuộc category đó.
interface Product {
id: number;
name: string;
categoryId: string;
}
const products: Product[] = [
{ id: 1, name: "Laptop Pro", categoryId: "electronics" },
{ id: 2, name: "Bàn phím cơ", categoryId: "electronics" },
{ id: 3, name: "Tiểu thuyết Z", categoryId: "books" },
{ id: 4, name: "Sách giáo khoa", categoryId: "books" },
];
// Định nghĩa kiểu cho đối tượng kết quả:
// Key là string (categoryId), value là một mảng Product (Product[])
type ProductsByCategory = Record<string, Product[]>;
const groupedProducts: ProductsByCategory = {};
products.forEach(product => {
const category = product.categoryId;
if (!groupedProducts[category]) {
// Nếu category này chưa có trong object, khởi tạo nó là một mảng rỗng
groupedProducts[category] = [];
}
// Thêm sản phẩm vào mảng của category tương ứng
groupedProducts[category].push(product);
});
console.log(groupedProducts);
/* Output:
{
electronics: [
{ id: 1, name: 'Laptop Pro', categoryId: 'electronics' },
{ id: 2, name: 'Bàn phím cơ', categoryId: 'electronics' }
],
books: [
{ id: 3, name: 'Tiểu thuyết Z', categoryId: 'books' },
{ id: 4, name: 'Sách giáo khoa', categoryId: 'books' }
]
}
*/
Ví dụ 3: Định nghĩa các quyền hạn cụ thể (Sử dụng Union Type cho Keys)
Đây là một cách dùng rất mạnh mẽ, khi bạn biết trước danh sách các key có thể có.
// Chỉ cho phép các vai trò này làm key
type UserRole = 'admin' | 'editor' | 'viewer';
interface Permissions {
canRead: boolean;
canWrite: boolean;
canDelete: boolean;
}
// Key BẮT BUỘC phải là 'admin', 'editor', hoặc 'viewer'
// Value BẮT BUỘC phải có hình dạng của interface Permissions
const rolePermissions: Record<UserRole, Permissions> = {
admin: { canRead: true, canWrite: true, canDelete: true },
editor: { canRead: true, canWrite: true, canDelete: false },
viewer: { canRead: true, canWrite: false, canDelete: false }
};
// Lợi ích:
// 1. TypeScript sẽ báo lỗi nếu bạn thiếu một vai trò nào đó (nếu bạn định nghĩa type chặt hơn).
// 2. Báo lỗi nếu bạn định nghĩa một vai trò không tồn tại:
// rolePermissions.guest = { ... }; // Lỗi! Property 'guest' does not exist on type 'Record<UserRole, Permissions>'.
// 3. Báo lỗi nếu cấu trúc value bị sai:
// rolePermissions.viewer = { canRead: true }; // Lỗi! Property 'canWrite' is missing...
Khi nào nên dùng Record?
Hãy sử dụng Record<Keys, Type> khi bạn cần định nghĩa kiểu cho một đối tượng mà:
Nó hoạt động như một Từ điển (Dictionary), Map, hoặc Hash Map.
Bạn cần nhóm dữ liệu từ một mảng vào một đối tượng để tra cứu nhanh.
Bạn muốn đảm bảo rằng tất cả các giá trị (values) trong một đối tượng phải tuân theo cùng một kiểu (Type).
Bạn muốn giới hạn các khóa (keys) chỉ được thuộc một tập hợp các giá trị cụ thể.
Record là một công cụ đơn giản nhưng cực kỳ hiệu quả để giúp code của bạn trở nên an toàn, rõ ràng và dễ đọc hơn.
