0

Series Total TypeScript Thực chiến #7: Generics và Constraints – Cảnh giới của Code Tái sử dụng

1. Nỗi đau của hàm "Hard-code" Type

Trong hệ thống AFC, bạn thường xuyên phải giao tiếp với Database. Giả sử bạn viết một hàm để cache dữ liệu. Ban đầu, bạn chỉ cần cache thông tin Thẻ (Card):

type Card = { id: string; balance: number };

function cacheCardData(key: string, data: Card): Card {
  // Logic lưu vào Redis...
  console.log(`Đã cache thẻ ${data.id}`);
  return data; // Trả về để dùng tiếp
}

Vài ngày sau, bạn cần cache thêm thông tin Trạm (Station). Bạn lại phải viết thêm một hàm mới:

type Station = { id: string; name: string };

function cacheStationData(key: string, data: Station): Station {
  // Logic y hệt bên trên
  console.log(`Đã cache trạm ${data.name}`);
  return data;
}

❌ Cách giải quyết tệ nhất: Chuyển tham số data thành type any. Khi dùng any, bạn đánh mất hoàn toàn khả năng gợi ý code và kiểm tra lỗi của TypeScript ở đầu ra.

2. Thuốc giải: Generics - "Biến" dành riêng cho Type

Generics cho phép bạn truyền Type vào một hàm giống hệt như cách bạn truyền tham số (Arguments) thông thường. Người ta thường dùng cặp ngoặc nhọn <T> (đại diện cho Type) để khai báo.

Hãy gộp 2 hàm trên lại thành một "Khuôn đúc vạn năng":

// Chữ <T> ở đây đóng vai trò như một biến chứa Type sẽ được truyền vào sau
function cacheData<T>(key: string, data: T): T {
  // Logic lưu vào Redis chung...
  console.log(`Đã cache dữ liệu vào key: ${key}`);
  return data;
}

Bây giờ, khi gọi hàm, TypeScript đủ thông minh để tự động "nội suy" (infer) ra T dựa trên giá trị bạn truyền vào:

const myCard: Card = { id: 'C01', balance: 50000 };
const myStation: Station = { id: 'S01', name: 'Bến Thành' };

// Trả về đúng type Card. Bạn gõ result1. sẽ gợi ý ra id, balance
const result1 = cacheData('card_C01', myCard); 

// Trả về đúng type Station. Bạn gõ result2. sẽ gợi ý ra id, name
const result2 = cacheData('station_S01', myStation);

3. Vấn đề phát sinh: Khuôn đúc "quá lỏng lẻo"

Generics <T> rất mạnh, nhưng nó có một điểm yếu: T có thể là bất cứ thứ gì.

Giả sử hệ thống cache của chúng ta yêu cầu mọi object truyền vào bắt buộc phải có trường id để làm khóa phụ. Nếu bạn giữ nguyên hàm cacheData<T> như trên, chuyện gì sẽ xảy ra nếu ai đó truyền vào một object không có id?

function cacheEntity<T>(data: T): T {
  // ❌ Lỗi đỏ lòm ở đây! 
  // TypeScript phàn nàn: Property 'id' does not exist on type 'T'.
  console.log(`Đang cache entity có ID: ${data.id}`); 
  return data;
}

TypeScript báo lỗi cực kỳ chuẩn xác: Vì T có thể là mảng, là chuỗi, là số, hoặc một object không có id, làm sao nó dám cho phép bạn gọi data.id?

4. Constraints: Siết chặt kỷ luật với từ khóa extends

Để khắc phục, chúng ta phải đặt ra Ràng buộc (Constraints) cho cái khuôn <T> này. Chúng ta dùng từ khóa extends để ra lệnh: "T có thể là cái gì cũng được, miễn là nó phải chứa thuộc tính id".

// Định nghĩa một Interface cơ sở
interface HasId {
  id: string;
}

// 🔥 Siết chặt Generics bằng extends
function cacheEntity<T extends HasId>(data: T): T {
  // ✅ Giờ thì gọi data.id thoải mái, IDE không báo lỗi nữa
  console.log(`Đang cache entity có ID: ${data.id}`);
  return data;
}

Thử nghiệm ranh giới bảo vệ:

const validCard = { id: 'C02', balance: 100000 };
cacheEntity(validCard); // ✅ Ngon lành vì object có trường id

const invalidData = { name: 'Hiếu', role: 'Engineer' };
cacheEntity(invalidData); 
// ❌ TypeScript chặn đứng ngay lúc gõ code:
// Argument of type '{ name: string; role: string; }' is not assignable to parameter of type 'HasId'.

5. Tuyệt kỹ nâng cao: Truy xuất Key an toàn bằng keyof T

Hãy kết hợp Generics với kỹ thuật keyof (từ Bài 1) để tạo ra một hàm getProperty siêu an toàn. Hàm này nhận vào một object, và một key, sau đó trả về giá trị của key đó.

Yêu cầu: Nếu người dùng truyền vào key KHÔNG CÓ trong object, hàm phải báo lỗi ngay lập tức.

// 1. T là type của Object truyền vào
// 2. K bị ràng buộc BẮT BUỘC phải là một trong những khóa (keys) của T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const turnstile = {
  ip: '192.168.1.10',
  status: 'active',
  firmware: 'v2.1'
};

// ✅ Hoạt động hoàn hảo, trả về đúng type 'string'
const ipAddr = getProperty(turnstile, 'ip'); 

// ❌ Báo lỗi ngay lập tức vì 'voltage' không tồn tại trong object turnstile
const vol = getProperty(turnstile, 'voltage');

Bằng việc thành thạo Generics và Constraints, bạn đã bước lên một nấc thang mới trong việc thiết kế kiến trúc phần mềm (Software Architecture) bằng TypeScript. Các thư viện khổng lồ như ORM (Prisma, TypeORM), hay các schema validator (Zod) đều được xây dựng dựa trên chính những kỹ thuật ép kiểu động mạnh mẽ này. Thay vì viết 10 hàm xử lý dữ liệu khác nhau, giờ đây bạn chỉ cần một Class hoặc một Function duy nhất có khả năng uốn nắn theo mọi cấu trúc dữ liệu của trạm thu phí!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí