Mapped types và conditional types trong TypeScript

1. Tổng quan

Trong dự án TypeScript, bạn thường phải viết nhiều kiểu gần giống nhau: Order, rồi ReadonlyOrder, OrderUpdate, OrderGetters… Viết tay từng kiểu vừa tốn công, vừa dễ lệch nhau khi Order thay đổi.

Mapped types và conditional types giải quyết đúng vấn đề này. Chúng cho phép bạn sinh kiểu mới từ kiểu có sẵn. Đọc xong bài, bạn sẽ tự viết được các utility type như Partial, PickByType, UnwrapPromise. Bạn cũng sẽ biết cách kiểm thử chúng và biết khi nào nên dừng lại.

2. Chuẩn bị

Bạn cần TypeScript 5.x với strict bật. Bài dùng Vitest để kiểm thử kiểu:

npm install -D typescript vitest
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

strict: true bật cả strictNullChecks. Một số kết quả trong bài, ví dụ việc undefined có khớp string hay không, phụ thuộc vào cờ này.

Các ví dụ dùng chung kiểu sau:

export interface Order {
  id: string;
  customerId: string;
  total: number;
  paid: boolean;
  note?: string;
}

3. Mapped types

3.1. Cú pháp cơ bản

Mapped type duyệt qua một tập key và tạo ra một property cho mỗi key:

type ReadonlyOrder = {
  readonly [K in keyof Order]: Order[K];
};

const order: ReadonlyOrder = {
  id: "o-1",
  customerId: "c-1",
  total: 250_000,
  paid: false,
};

order.paid = true; // Lỗi biên dịch

Giải thích:

  • keyof Order là union "id" | "customerId" | "total" | "paid" | "note".
  • [K in ...] lặp qua từng key trong union đó.
  • Order[K] là indexed access type. Nó lấy kiểu của property K.
  • Mapped type dạng [K in keyof T] được gọi là homomorphic. Dạng này giữ nguyên modifier gốc, nên note vẫn là optional.

Dòng gán cuối báo lỗi:

error TS2540: Cannot assign to 'paid' because it is a read-only property.

Ví dụ trên vẫn gắn cứng vào Order. Để tái sử dụng cho mọi kiểu, bạn thêm tham số generic:

type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };

type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }

Đây chính là cách thư viện chuẩn định nghĩa Readonly<T> và Partial<T>.

3.2. Thêm và bỏ modifier

Bạn có thể đặt + hoặc - trước readonly và ?. Dấu + là mặc định nên thường được bỏ qua. Dấu - dùng để gỡ modifier:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type CompleteOrder = Concrete<Order>;

const draft: CompleteOrder = {
  id: "o-2",
  customerId: "c-9",
  total: 99_000,
  paid: true,
  note: "Giao giờ hành chính",
};

Với Concrete<Order>, property note trở thành bắt buộc. Kiểu của nó là string, không còn là string | undefined. Thư viện chuẩn có sẵn Required<T> làm đúng việc này.

3.3. Đổi tên key với as

Từ TypeScript 4.1, bạn có thể đổi tên key bằng mệnh đề as. Cách này thường đi kèm template literal type:

interface Customer {
  id: string;
  name: string;
  email: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;
// {
//   getId: () => string;
//   getName: () => string;
//   getEmail: () => string;
// }

Phần string & K là bắt buộc. keyof T có thể chứa cả number và symbol, trong khi Capitalize chỉ nhận string. Phép giao loại bỏ các key không phải chuỗi.

Bạn cũng có thể dùng as để lọc key. Nếu biểu thức trả về never, key đó bị loại khỏi kết quả:

type WithoutId<T> = {
  [K in keyof T as Exclude<K, "id">]: T[K];
};

type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }

Exclude ở đây là một conditional type. Đó là chủ đề của phần tiếp theo.

4. Conditional types

4.1. Cú pháp extends ? :

Conditional type hoạt động như toán tử ba ngôi, nhưng ở cấp kiểu:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

T extends string nghĩa là “T có gán được cho string không”. Đây không phải quan hệ kế thừa class.

4.2. Tính phân phối (distributive)

Khi T là tham số generic đứng trần (naked) và bạn truyền vào một union, TypeScript áp dụng điều kiện cho từng thành viên của union:

type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type C = ToArray<string | number>;        // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]

Hai kết quả khác nhau hoàn toàn. Bọc T trong tuple [T] sẽ tắt tính phân phối.

Tính phân phối là nền tảng của Exclude và Extract:

type MyExclude<T, U> = T extends U ? never : T;

type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"

TypeScript xét từng thành viên. "cancelled" biến thành never, và never tự biến mất khỏi union.

Lưu ý: Một conditional type phân phối nhận never sẽ trả về never, bởi never là union rỗng. Ví dụ ToArray<never> là never, không phải never[]. Nếu bạn cần phát hiện never, hãy dùng dạng bọc tuple: [T] extends [never] ? ... : ....

4.3. Trích kiểu với infer

infer cho phép bạn “bắt” một phần của kiểu trong nhánh điều kiện:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

async function fetchOrder(id: string): Promise<Order> {
  return { id, customerId: "c-1", total: 0, paid: false };
}

type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>;   // Order
type Item = ElementOf<Order[]>;                    // Order

Ý nghĩa từng phần:

  • Promise<infer U> khai báo biến kiểu U và để TypeScript tự suy ra giá trị của nó.
  • Nếu khớp, nhánh true dùng được U. Nếu không khớp, UnwrapPromise trả lại T nguyên vẹn.
  • ReturnType và Parameters trong thư viện chuẩn cũng được viết bằng infer.

Trong thực tế, bạn nên dùng Awaited<T> có sẵn. Nó xử lý được cả Promise lồng nhau như Promise<Promise<Order>>.

5. Kết hợp hai kỹ thuật

5.1. Chọn property theo kiểu giá trị

Đặt conditional type vào trong mệnh đề as, bạn sẽ lọc được key theo kiểu của giá trị:

type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }

Hãy để ý note không xuất hiện trong OrderStrings. Kiểu của Order["note"] là string | undefined, và kiểu này không gán được cho string khi bật strictNullChecks. Ở đây T[K] không phải tham số trần, nên điều kiện không phân phối. Union được xét như một khối.

5.2. Handler cho event có kiểu chặt

Đây là một ví dụ gần với code thật. Bạn có một bảng event cho đơn hàng và muốn sinh ra kiểu cho các handler:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; paidAt: Date };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};

const handlers: EventHandlers<OrderEvents> = {
  onCreated: (p) => console.log(`Tạo đơn ${p.orderId}: ${p.total}`),
  onPaid: (p) => console.log(`Đã thanh toán lúc ${p.paidAt.toISOString()}`),
  onCancelled: (p) => console.log(`Huỷ vì: ${p.reason}`),
};

handlers.onCreated({ orderId: "o-1", total: 250_000 });
Tạo đơn o-1: 250000

Tham số p trong mỗi handler được suy ra đúng kiểu, nên bạn không phải khai báo lại. Khi thêm event refunded vào OrderEvents, TypeScript sẽ báo lỗi ngay vì handlers thiếu onRefunded.

5.3. Cài đặt runtime cho kiểu đã sinh

Mapped type chỉ tồn tại lúc biên dịch. Muốn có object thật, bạn vẫn phải tự viết logic:

export function toGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key of Object.keys(obj) as (keyof T & string)[]) {
    const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    result[name] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

Tại sao cần ép kiểu? TypeScript không thể chứng minh rằng vòng lặp runtime tạo ra đúng hình dạng Getters<T>. Phép ép as unknown as là chỗ bạn tự cam kết điều đó. Vì vậy, đoạn code này bắt buộc phải có test.

6. Kiểm thử kiểu với Vitest

Vitest có expectTypeOf để kiểm tra kiểu, cùng các assertion runtime thông thường:

import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";

describe("utility types", () => {
  it("PickByType chỉ giữ property có kiểu khớp", () => {
    expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
    expectTypeOf<PickByType<Order, string>>()
      .toEqualTypeOf<{ id: string; customerId: string }>();
  });

  it("UnwrapPromise bóc Promise một lớp", () => {
    expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  it("toGetters trả về getter đúng giá trị", () => {
    const getters = toGetters({ id: "c-1", name: "An", email: "an@example.com" });

    expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
    expect(getters.getName()).toBe("An");
    expect(getters.getEmail()).toBe("an@example.com");

    // @ts-expect-error getter không tồn tại phải báo lỗi biên dịch
    getters.getPhone;
  });
});

Chạy test kèm kiểm tra kiểu:

npx vitest run --typecheck

Lưu ý những điểm sau:

  • Ở runtime, expectTypeOf không làm gì cả. Kiểm tra thật sự diễn ra khi trình biên dịch phân tích file. Vì vậy bạn cần cờ --typecheck, hoặc chạy thêm npx tsc --noEmit trong CI.
  • // @ts-expect-error dùng cho test phủ định. Nếu dòng bên dưới không còn lỗi, chẳng hạn khi ai đó làm kiểu lỏng hơn, chính directive này sẽ gây lỗi biên dịch.
  • Test runtime cho toGetters giúp bạn bắt được trường hợp logic runtime lệch khỏi kiểu đã cam kết.

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

Quên string & K khi dùng template literal. Viết `get${Capitalize<K>}` sẽ báo lỗi, vì K có thể là number | symbol.

Không để ý tính phân phối. Bạn viết IsString<string | number> và mong nhận false, nhưng kết quả là boolean, tức true | false. Nếu muốn xét cả union như một khối, hãy bọc tuple.

Nhầm lẫn khi dùng extends với any. any khiến điều kiện trả về hợp của cả hai nhánh. Ví dụ IsString<any> là boolean.

Mất modifier khi map qua key không homomorphic. Mapped type [K in "id" | "total"]: Order[K] không giữ readonly hay ? của Order. Chỉ dạng [K in keyof T] mới giữ modifier. Nếu bạn cần chọn một số key mà vẫn giữ modifier, hãy dùng Pick.

Đệ quy quá sâu. Kiểu như DeepPartial<T> áp dụng đệ quy lên object lồng nhau có thể gây lỗi Type instantiation is excessively deep and possibly infinite. Nó cũng làm editor chậm rõ rệt. Ngoài ra, Date, Map và mảng cũng là object, nên điều kiện T[K] extends object thường bắt nhầm chúng.

Lưu ý: Đừng dùng mapped types và conditional types chỉ để “trông thông minh”. Nếu kiểu chỉ dùng ở một chỗ và hiếm khi đổi, một interface viết tay sẽ dễ đọc hơn nhiều. Thông báo lỗi từ kiểu lồng 3–4 tầng rất khó hiểu với đồng nghiệp. Hãy ưu tiên utility có sẵn (Partial, Pick, Omit, Record, Exclude, ReturnType, Awaited) trước khi tự viết.

Tin rằng kiểu bảo vệ được runtime. Mapped type không kiểm tra dữ liệu từ API. Nếu JSON trả về thiếu field, Getters<Customer> vẫn “đúng” lúc biên dịch nhưng sai lúc chạy. Với dữ liệu từ bên ngoài, bạn cần thêm bước validate ở runtime.

8. Kết luận

Bài viết đã giới thiệu hai công cụ cốt lõi để sinh kiểu trong TypeScript:

  • Mapped types, với [K in keyof T], cho phép duyệt key, thêm hoặc bỏ readonly/?, đổi tên và lọc key bằng as.
  • Conditional types, với T extends U ? X : Y, cho phép rẽ nhánh ở cấp kiểu. Chúng phân phối trên union và trích kiểu con bằng infer.

Khi kết hợp hai kỹ thuật, bạn viết được các kiểu như PickByType hay EventHandlers. Các kiểu này tự cập nhật khi kiểu gốc thay đổi. Hãy luôn kèm test kiểu bằng expectTypeOf và @ts-expect-error. Hãy chạy --typecheck trong CI. Và hãy giữ các kiểu này đủ đơn giản để người khác đọc hiểu được.

typescriptmapped typesconditional typesgenericstype system