TypeScript Generics qua ví dụ thực tế

1. Tổng quan

Generics là công cụ giúp bạn viết code tái sử dụng được mà vẫn giữ nguyên type safety. Thay vì viết UserRepository, OrderRepository, ProductRepository lặp đi lặp lại, bạn viết một Repository<T> dùng chung. Bài này đi qua các ví dụ thực tế: generic function, generic class, constraints, multiple type parameters, và cách kết hợp với utility types. Đọc xong, bạn sẽ tự tin áp dụng generics vào các tầng service, repository, API response trong dự án TypeScript của mình.

2. Generics là gì và tại sao cần

Generics cho phép bạn “tham số hóa” type, giống như function nhận parameter nhưng ở cấp độ type.

// Không dùng generics: phải viết lại cho từng type
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// Dùng generics: một function cho mọi type
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string, suy luận tự động
const age = first([30, 25]);          // T = number

<T> là type parameter — một biến đại diện cho type thực tế sẽ được truyền vào lúc gọi. TypeScript tự suy luận (infer) T dựa trên argument, bạn không cần chỉ định tay.

Lưu ý: T, U, K, V chỉ là convention đặt tên, không có ý nghĩa đặc biệt. Với codebase lớn, đặt tên rõ nghĩa hơn như TItem, TResponse sẽ dễ đọc hơn nhiều.

3. Generic function trong thực tế: hàm groupBy

Một ví dụ hay gặp khi xử lý dữ liệu: nhóm các object theo một field.

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • T là type của item trong array (Order).
  • K extends string | number giới hạn key chỉ được là string hoặc number — vì object key không thể là kiểu khác.
  • Kết quả byStatus có type Record<"pending" | "paid" | "cancelled", Order[]>, TypeScript biết chính xác các key có thể xuất hiện.
byStatus.paid       -> Order[] (2 phần tử)
byStatus.pending    -> Order[] (1 phần tử)
byStatus.cancelled  -> Order[] (có thể undefined nếu strict)

4. Generic constraints với extends

Constraint giới hạn T phải thỏa mãn một shape nào đó, giúp bạn truy cập property an toàn bên trong function generic.

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "An", email: "an@example.com" },
  { id: 2, name: "Bình", email: "binh@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

Nếu không có extends HasId, dòng item.id sẽ báo lỗi vì TypeScript không biết T có property id hay không.

error TS2339: Property 'id' does not exist on type 'T'.

Đây là lỗi phổ biến nhất khi mới học generics: quên constraint rồi truy cập property tùy ý trên T.

5. Generic class: ví dụ Repository<T>

Đây là use case kinh điển — một repository generic dùng cho nhiều entity.

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Bàn phím", price: 500_000 });
productRepo.save({ id: 2, name: "Chuột", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity> đảm bảo mọi entity được quản lý đều có id, nên Map<number, T> dùng id làm key an toàn.
  • Khi khai báo new Repository<Product>(), mọi method trả về đều có type Product chính xác — IDE autocomplete name, price ngay.

Lưu ý: Nếu bạn chỉ dùng Repository cho một entity duy nhất trong toàn bộ dự án, generics là over-engineering. Hãy viết class cụ thể trước, chỉ generic hóa khi có ≥ 2 nơi dùng chung logic.

6. Multiple type parameters: ví dụ ApiResponse<TData, TError>

Khi cần tham số hóa nhiều hơn một type, bạn dùng nhiều type parameter cùng lúc.

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

interface User {
  id: number;
  username: string;
}

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "Không tìm thấy user",
});

TError = string ở dòng khai báo interface là default generic type — nếu bạn không truyền type thứ hai, TypeScript tự dùng string. Đây là lý do ApiResponse<User> vẫn hợp lệ dù chỉ có 1 type argument.

7. Kết hợp generics với utility types

Utility types như Partial, Pick, Omit thực chất cũng là generic type được định nghĩa sẵn trong TypeScript. Kết hợp chúng với generics của riêng bạn rất hiệu quả cho các hàm update.

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
  • Partial<Omit<T, "id">> nghĩa là: lấy tất cả field của T trừ id, rồi làm cho tất cả field đó optional.
  • Nhờ vậy, updateEntity không cho phép sửa id, và caller chỉ cần truyền field muốn thay đổi — rất giống pattern PATCH trong REST API.

Nếu thử truyền { id: 2 } vào patch, TypeScript sẽ báo lỗi ngay vì id đã bị Omit loại khỏi type cho phép:

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. Generic với conditional type: khi nào cần infer

Một bước nâng cao hơn: lấy type trả về của một function một cách tự động, tránh viết tay trùng lặp.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "An", email: "an@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, không phải Promise<Customer>
  • infer U cho TypeScript “đoán” type bên trong Promise<...> và gán vào U.
  • Bạn không cần biết trước fetchCustomer trả về gì — type được suy ra tự động, nếu signature của fetchCustomer đổi, FetchResult tự cập nhật theo.

Lưu ý: Conditional type với infer rất mạnh nhưng dễ làm code khó đọc. Chỉ dùng khi bạn cần type utility tái sử dụng nhiều lần (như UnwrapPromise), không nên lạm dụng cho logic một lần dùng.

9. Viết test cho generic function

Generics ảnh hưởng đến type-checking lúc compile, nhưng behavior lúc runtime vẫn cần test như bình thường. Cài Vitest:

<!-- package.json (phần dependencies, không phải pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("lưu và tìm lại đúng product theo id", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "Bàn phím" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("Bàn phím");
  });

  it("trả về undefined khi id không tồn tại", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

Chạy npx vitest run, kết quả mong đợi:

✓ Repository<Product> > lưu và tìm lại đúng product theo id
✓ Repository<Product> > trả về undefined khi id không tồn tại

Test Files  1 passed (1)
     Tests  2 passed (2)

Test này không kiểm tra type (TypeScript compiler đã làm việc đó lúc build), mà kiểm tra logic runtime của class generic — đảm bảo Map hoạt động đúng bất kể T là gì.

10. Lỗi hay gặp và khi nào không nên dùng generics

  • Generic thừa (over-generic): tạo Repository<T> nhưng cả dự án chỉ có một entity. Lúc này class cụ thể CustomerRepository dễ đọc và dễ maintain hơn.
  • Quên constraint: viết function getProp<T>(obj: T, key: string) rồi truy cập obj[key] sẽ bị lỗi type any hoặc lỗi index signature. Nên dùng <T, K extends keyof T>(obj: T, key: K): T[K].
  • Dùng any thay vì generic: function identity(x: any): any làm mất hoàn toàn type safety — TypeScript không còn báo lỗi gì cả. Luôn ưu tiên <T>(x: T): T.
  • Generic quá nhiều type parameter: nếu function có 4-5 type parameter, code rất khó đọc. Hãy xem xét gom các type lại thành một interface config duy nhất.
// Khó đọc
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// Dễ đọc hơn
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

Lưu ý: Nếu một type parameter chỉ được dùng đúng một lần trong toàn bộ function signature, rất có thể bạn không cần generic — một type cụ thể hoặc unknown là đủ.

11. Kết luận

Generics giúp bạn viết code dùng lại được mà không đánh đổi type safety: một Repository<T>, một ApiResponse<TData, TError> thay cho hàng chục class/interface trùng lặp. Chìa khóa là nắm vững ba thứ — type parameter cơ bản, constraint với extends, và cách kết hợp với utility types có sẵn (Partial, Omit, Pick). Đừng generic hóa mọi thứ ngay từ đầu; chỉ rút ra generic khi bạn thấy logic lặp lại ở ít nhất hai nơi khác nhau trong codebase.

typescriptgenericsjavascripttype-safetyfrontend