Generics nâng cao trong TypeScript

Generics nâng cao trong TypeScript

Nếu bạn đã dùng Array<T> hay viết một hàm identity<T>(x: T): T, bạn đã chạm vào Generics. Nhưng phần lớn dev dừng ở mức "thay thế cho any". Bài này đi sâu hơn: cách dùng generics như một công cụ suy luận kiểu (type inference) để compiler tự hiểu ý bạn, thay vì bạn phải khai báo thủ công.

#1. Generics thực sự giải quyết vấn đề gì?

Hãy nhìn vấn đề trước khi nhìn giải pháp. Giả sử bạn viết một hàm lấy phần tử đầu của mảng:

function first(arr: any[]): any {
  return arr[0];
}
 
const n = first([1, 2, 3]); // n: any  ❌ mất kiểu
n.toUpperCase(); // không báo lỗi, nhưng crash lúc runtime

Dùng any khiến compiler "mù". Generics cho phép giữ lại quan hệ giữa input và output:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
const n = first([1, 2, 3]); // n: number ✅
const s = first(["a", "b"]); // s: string ✅

Điểm mấu chốt: T không phải một kiểu cố định — nó là một biến kiểu mà compiler tự điền dựa trên cách bạn gọi hàm. Đây gọi là type inference.

#2. Constraints — giới hạn cho type parameter

T tự do quá đôi khi lại không tốt. Giả sử bạn muốn một hàm đọc thuộc tính length:

function logLength<T>(item: T): T {
  console.log(item.length); // ❌ Error: Property 'length' does not exist on type 'T'
  return item;
}

Compiler đúng: vì T có thể là bất cứ gì, nó không đảm bảo có length. Ta dùng extends để ràng buộc:

function logLength<T extends { length: number }>(item: T): T {
  console.log(item.length); // ✅ OK
  return item;
}
 
logLength("hello");      // ✅ string có length
logLength([1, 2, 3]);    // ✅ array có length
logLength(123);          // ❌ number không có length

T extends { length: number } nghĩa là: "T có thể là bất kỳ kiểu nào, miễn là nó có thuộc tính length kiểu number". Đây là lúc generics bắt đầu mạnh — bạn vừa giữ tính tổng quát, vừa đảm bảo an toàn.

#3. Ràng buộc giữa các type parameter với keyof

Đây là pattern xuất hiện cực nhiều trong thư viện thật. Hàm getProperty lấy giá trị của một key trong object, và phải đảm bảo key đó thực sự tồn tại:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { name: "Loc", age: 30, isAdmin: true };
 
const name = getProperty(user, "name");   // name: string ✅
const age = getProperty(user, "age");     // age: number ✅
const x = getProperty(user, "email");     // ❌ Error: "email" không phải key của user

Phân tích từng phần:

  • keyof T tạo ra một union của tất cả các key: "name" | "age" | "isAdmin".
  • K extends keyof T ràng buộc K chỉ được là một trong các key đó.
  • T[K]indexed access type — kiểu của giá trị tại key K. Nếu K"age" thì T[K]number.

Kết quả: compiler tự suy ra kiểu trả về chính xác cho từng key, và chặn key không tồn tại ngay lúc viết code.

#4. Default type parameters

Giống tham số mặc định của hàm, type parameter cũng có giá trị mặc định:

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}
 
const r1: ApiResponse = { data: "anything", status: 200 };        // T = unknown
const r2: ApiResponse<User[]> = { data: [], status: 200 };        // T = User[]

Dùng unknown làm default an toàn hơn any, vì nó buộc người dùng phải narrow kiểu trước khi dùng.

#5. Một lỗi tư duy phổ biến: type parameter không dùng

Một dấu hiệu code "giả generic":

// ❌ Anti-pattern: T xuất hiện đúng một lần
function parse<T>(input: string): T {
  return JSON.parse(input);
}
 
const data = parse<User>("..."); // trông type-safe nhưng KHÔNG

Hàm này nói dối. Nó hứa trả về T nhưng thực ra JSON.parse trả any — không hề kiểm tra runtime. T chỉ xuất hiện ở output, không ở input, nên compiler không suy luận được gì, chỉ tin lời bạn. Đây là type assertion trá hình. Quy tắc: nếu một type parameter chỉ xuất hiện đúng một chỗ, nhiều khả năng bạn đang ép kiểu chứ không phải dùng generics đúng cách.

#Bài tập

Làm lần lượt từ dễ đến khó. Viết code trong widget bên dưới, hệ thống sẽ chấm và giải thích lỗi cho bạn.

Đang tải bài tập...
Đang tải bài tập...
Đang tải bài tập...

#Tổng kết

Những điểm cần khắc sâu sau bài này:

  1. Generics giữ lại quan hệ kiểu giữa input và output — đó là lý do tồn tại của nó, không phải để thay any.
  2. extends đặt constraint lên type parameter, vừa giữ tổng quát vừa đảm bảo an toàn.
  3. K extends keyof T cộng với T[K] là pattern nền tảng để type-safe khi truy cập thuộc tính động.
  4. Default type parameter (= unknown) giúp API linh hoạt mà vẫn an toàn.
  5. Nếu type parameter chỉ xuất hiện một lần, hãy nghi ngờ — có thể bạn đang ép kiểu.

Buổi tới: Conditional Types — cách viết kiểu "if/else" ngay trong type system.