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 Orderlà 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 propertyK.- Mapped type dạng
[K in keyof T]được gọi là homomorphic. Dạng này giữ nguyên modifier gốc, nênnotevẫ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
neversẽ trả vềnever, bởineverlà union rỗng. Ví dụToArray<never>lànever, không phảinever[]. Nếu bạn cần phát hiệnnever, 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ểuUvà để TypeScript tự suy ra giá trị của nó.- Nếu khớp, nhánh
truedùng đượcU. Nếu không khớp,UnwrapPromisetrả lạiTnguyên vẹn. ReturnTypevàParameterstrong thư viện chuẩn cũng được viết bằnginfer.
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,
expectTypeOfkhô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êmnpx tsc --noEmittrong CI. // @ts-expect-errordù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
toGettersgiú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ằngas. - 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ằnginfer.
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.