TypeScript Generics thực chiến — Viết code tổng quát mà vẫn an toàn

Phong Hy

Code TypeScript trên màn hình Ảnh: anshul kumar — Pexels

Generics — thứ ai cũng nghe tên, ít ai dùng đúng

Hồi mới học TypeScript, mình cứ tưởng generics là thứ gì đó cao siêu chỉ dành cho mấy thư viện như React, Lodash hay Prisma. Ai ngờ một ngày đẹp trời, đang refactor utility function dùng chung, mình mới thấy nó gần gũi hơn tưởng. Bài này kể lại hành trình đó — từ generic function đơn giản tới mấy trick keyof, infer.

Bắt đầu từ cái hàm đơn giản

Kiểu dễ nhất để hiểu generics là nhìn vào tình huống "viết 3 lần vì khác type". Ví dụ muốn lấy phần tử đầu tiên của mảng:

function first(arr: string[]) {
  return arr[0];
}

// Giờ muốn dùng cho number[]? Viết thêm hàm khác? Hay ép kiểu?

Viết thêm hàm cho mỗi type thì không ổn. Ép kiểu thì mất an toàn. Generics giải quyết bằng một chỗ ngồi cho type:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const name = first(["Lumi", "Phong"]);   // string | undefined
const price = first([200_000, 350_000]); // number | undefined

Cái hay là TypeScript tự suy ra T từ đối số, không cần khai báo first<string>() làm gì. Và không mất type safety: price vẫn biết là number | undefined.

Ràng buộc bằng extends — generics có giới hạn

Generic không có nghĩa là "nhét gì cũng được". Có những lúc mình chỉ muốn nhận object thôi. Đó là lúc extends phát huy tác dụng:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Lumi" };
getProperty(user, "name"); // OK — string
getProperty(user, "age");  // Lỗi ngay tại compile time

K extends keyof T nghĩa là K phải là một key có thật của T. Kiểu sai thì IDE báo đỏ liền, khỏi đợi chạy mới vỡ. Cái này mình thấy cực kỳ hữu ích khi viết hàm map dữ liệu từ API — backend đổi field là TypeScript la làng ngay.

Một chút infer cho người muốn đi xa hơn

Đây là phần hồi xưa mình sợ nhất, giờ thấy cũng không ghê gớm. infer dùng trong conditional type để "bắt" type nằm bên trong một type khác:

type ElementOf<T> = T extends Array<infer U> ? U : never;

type A = ElementOf<string[]>;   // string
type B = ElementOf<number[]>;   // number

infer U kiểu như nói "nếu T là mảng thì U chính là kiểu phần tử, bắt nó ra cho tao". Zod, tRPC xài cái này tùm lum để suy type từ runtime schema. Mình không cần viết mỗi ngày, nhưng hiểu nó thì đọc type của mấy thư viện đỡ hoảng hơn nhiều.

Kinh nghiệm thực tế của mình

Lập trình viên đang gõ code Ảnh: cottonbro studio — Pexels

Sau một thời gian vật lộn, mình rút ra mấy cái:

  1. Bắt đầu từ nhu cầu thật, đừng generic vì "nghe cho ngầu". Nếu hàm chỉ dùng một type, cứ viết type đó thẳng ra. Generics trả giá bằng độ phức tạp khi đọc.
  2. unknown thay vì any khi viết generic linh hoạt — any tắt luôn type checking, còn unknown ép mình narrow type trước khi dùng.
  3. Đặt tên type param dễ hiểu: T, K, V cho generic ngắn là chuẩn, nhưng phức tạp hơn thì dùng tên rõ nghĩa như TItem, TError — review dễ thở hơn.
  4. Đừng tự build generic framework khi chỉ cần dùng thư viện có sẵn. Mình từng viết cả một hệ thống generic type cho API client, cuối cùng xoá sạch thay bằng Zod + tRPC — nhanh hơn, an toàn hơn.

Kết

Generics không phải phép màu, nó chỉ là cách nói với TypeScript: "tao biết type sẽ khác nhau, nhưng mày vẫn phải kiểm tra cho tao". Nắm được T, extends, keyof là đã đi được 80% quãng đường rồi. Còn infer thì để dành lúc rảnh, gặp rồi sẽ hiểu.

Có ai từng over-engineer generics như mình hông? Kể nghe chơi, để mình biết mình không cô đơn 😄

📋 Phụ lục thuật ngữ

  • Generic — tham số hoá kiểu dữ liệu, cho phép hàm/type làm việc với nhiều type mà vẫn giữ type safety
  • Constraint (extends) — ràng buộc type parameter phải thoả điều kiện (vd: là key của object)
  • keyof — lấy union của tất cả keys của một type
  • Conditional type — type được chọn theo điều kiện, kiểu T extends U ? X : Y
  • infer — khai báo biến type bên trong conditional type để "bắt" type con