keyof, typeof và indexed access types trong TypeScript
1. Tổng quan
Khi codebase lớn dần, bạn sẽ gặp cảnh cùng một tập key, chẳng hạn trạng thái đơn hàng, xuất hiện ở ba bốn nơi: trong object, trong union type, trong tham số hàm. Sửa một nơi mà quên nơi khác là có bug.
TypeScript có ba công cụ để chỉ khai báo một lần rồi suy ra phần còn lại. keyof lấy tập key của một type. typeof lấy type từ một giá trị. Indexed access type (T["key"]) lấy type của một thuộc tính.
Đọc xong bài này, bạn sẽ biết cách kết hợp ba công cụ đó để viết hàm generic an toàn và xây một event bus có type chặt chẽ. Bạn cũng sẽ biết những lỗi hay gặp khi dùng chúng.
2. Chuẩn bị
Bài dùng TypeScript 5.x và Vitest để viết test. Bạn cài như sau:
npm install --save-dev typescript vitest
Bạn nên bật strict trong tsconfig.json. Nếu không bật, nhiều lỗi về undefined sẽ không bị báo:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. Toán tử keyof
3.1. Cách dùng cơ bản
keyof T trả về một union gồm tên các thuộc tính của T:
type Order = {
id: string;
total: number;
status: "pending" | "paid" | "cancelled";
note?: string;
};
type OrderKey = keyof Order;
// "id" | "total" | "status" | "note"
const sortBy: OrderKey = "total"; // OK
const wrong: OrderKey = "price"; // Lỗi
Dòng cuối không biên dịch được:
error TS2322: Type '"price"' is not assignable to type 'keyof Order'.
Bạn không phải gõ tay union "id" | "total" | .... Khi thêm thuộc tính mới vào Order, OrderKey tự cập nhật theo.
3.2. keyof với index signature
Nếu type dùng index signature, kết quả có thể khiến bạn bất ngờ:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey là string | number chứ không chỉ string. Lý do là trong JavaScript, obj[1] và obj["1"] truy cập cùng một thuộc tính. Vì vậy TypeScript cho phép cả key kiểu number.
3.3. keyof trong generic
keyof phát huy tác dụng nhất khi đi cùng generic constraint:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
const total = getProperty(order, "total"); // number
const status = getProperty(order, "status"); // "pending" | "paid" | "cancelled"
getProperty(order, "price"); // Lỗi biên dịch
Hàm này làm được mấy việc sau:
K extends keyof Tgiới hạnkey, nên nó chỉ nhận tên thuộc tính có thật củaobj.- Kiểu trả về
T[K]là một indexed access type, sẽ nói ở mục 5. Nhờ nó, mỗi lần gọi hàm sẽ nhận đúng type của thuộc tính tương ứng. - Gọi với
"total"thì nhậnnumber. Gọi với"status"thì nhận union literal. Không cóanynào ở đây.
4. Toán tử typeof trong ngữ cảnh type
JavaScript vốn có typeof chạy lúc runtime và trả về chuỗi như "string" hay "object". TypeScript thêm một nghĩa thứ hai: khi đặt ở vị trí type, typeof x cho bạn type của biến x.
4.1. Lấy type từ giá trị
const defaultCustomer = {
name: "Nguyễn Văn An",
email: "an@example.com",
loyaltyPoints: 0,
};
type Customer = typeof defaultCustomer;
// { name: string; email: string; loyaltyPoints: number }
function createCustomer(input: Partial<Customer>): Customer {
return { ...defaultCustomer, ...input };
}
console.log(createCustomer({ name: "Trần Thị Bình" }));
Kết quả:
{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }
Ở đây giá trị mặc định là nguồn sự thật duy nhất. Bạn thêm field vào defaultCustomer thì Customer cũng có field đó.
Với hàm, bạn kết hợp typeof với utility type có sẵn:
function loadConfig() {
return { apiUrl: "https://api.shop.local", timeoutMs: 5000, retries: 3 };
}
type AppConfig = ReturnType<typeof loadConfig>;
// { apiUrl: string; timeoutMs: number; retries: number }
4.2. as const và typeof
Mặc định TypeScript “nới rộng” literal: "pending" thành string, mảng thành string[]. Thêm as const sẽ giữ nguyên literal và đánh dấu readonly:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
Nếu thiếu as const, typeof ORDER_STATUSES chỉ là string[] và bạn mất hết thông tin literal.
4.3. Kết hợp keyof typeof
Đây là cách viết rất phổ biến khi bạn có một object hằng số và muốn lấy union các key của nó:
const SHIPPING_FEES = {
standard: 30_000,
express: 60_000,
sameDay: 120_000,
} as const;
type ShippingMethod = keyof typeof SHIPPING_FEES;
// "standard" | "express" | "sameDay"
function shippingFee(method: ShippingMethod): number {
return SHIPPING_FEES[method];
}
console.log(shippingFee("express"));
60000
Cách đọc là từ trong ra ngoài. typeof SHIPPING_FEES lấy type của object, sau đó keyof lấy các key của type đó. Bạn không viết keyof SHIPPING_FEES được, vì SHIPPING_FEES là giá trị chứ không phải type.
5. Indexed access types
5.1. Lấy type của một thuộc tính
Cú pháp T["key"] giống truy cập thuộc tính, nhưng diễn ra ở tầng type:
type OrderStatus = Order["status"]; // "pending" | "paid" | "cancelled"
type OrderNote = Order["note"]; // string | undefined
type IdOrTotal = Order["id" | "total"]; // string | number
type AnyOrderValue = Order[keyof Order]; // union type của mọi thuộc tính
Có ba điểm cần để ý:
- Thuộc tính optional (
note?) cho ra type có kèmundefined. - Truyền vào một union key thì nhận về union các type tương ứng.
T[keyof T]là cách thường dùng để lấy “type của mọi giá trị” trong object.
5.2. Lấy type phần tử của mảng
Index bằng number sẽ cho bạn type của phần tử:
type OrderStatusFromList = (typeof ORDER_STATUSES)[number];
// "pending" | "paid" | "shipped" | "cancelled"
function isOrderStatus(value: string): value is OrderStatusFromList {
return (ORDER_STATUSES as readonly string[]).includes(value);
}
console.log(isOrderStatus("paid"), isOrderStatus("refunded"));
true false
Mảng ORDER_STATUSES giờ phục vụ hai việc. Lúc runtime, nó là danh sách để validate input từ API. Lúc biên dịch, nó sinh ra union type. Bạn chỉ khai báo trạng thái đúng một lần.
5.3. Truy cập lồng nhau
Bạn có thể nối nhiều lần index để đi sâu vào cấu trúc:
type Invoice = {
customer: { name: string; address: { city: string; zip: string } };
lines: Array<{ sku: string; quantity: number; unitPrice: number }>;
};
type City = Invoice["customer"]["address"]["city"]; // string
type InvoiceLine = Invoice["lines"][number];
// { sku: string; quantity: number; unitPrice: number }
function lineTotal(line: InvoiceLine): number {
return line.quantity * line.unitPrice;
}
InvoiceLine được lấy thẳng từ Invoice, nên bạn không cần khai báo thêm một type riêng rồi phải giữ cho hai bên đồng bộ.
6. Ví dụ thực tế: event bus có type chặt chẽ
Bây giờ ghép cả ba công cụ lại. Mục tiêu là một event bus mà payload của mỗi event được kiểm tra đúng type:
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; method: "card" | "cash" };
cancelled: { orderId: string; reason: string };
};
type Handler<K extends keyof OrderEvents> = (payload: OrderEvents[K]) => void;
export class OrderEventBus {
private handlers: { [K in keyof OrderEvents]?: Handler<K>[] } = {};
on<K extends keyof OrderEvents>(event: K, handler: Handler<K>): void {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
this.handlers[event]?.forEach((handle) => handle(payload));
}
}
const bus = new OrderEventBus();
bus.on("paid", (p) => console.log(`Đơn ${p.orderId} thanh toán bằng ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // Lỗi: thiếu 'method'
// bus.emit("refunded", { orderId: "ORD-42" }); // Lỗi: event không tồn tại
Output:
Đơn ORD-42 thanh toán bằng card
Từng phần hoạt động như sau:
keyof OrderEventsgiới hạn tên event ở ba giá trị"created" | "paid" | "cancelled".OrderEvents[K]gắn payload với đúng event. Trong handler của"paid", tham sốpcómethodchứ không córeason.- Mapped type
[K in keyof OrderEvents]tạo kho lưu handler, mỗi event có một danh sách riêng. - Muốn thêm event
refunded, bạn chỉ sửaOrderEvents. Mọi chỗ khác tự cập nhật theo.
7. Kiểm thử type với Vitest
Vitest có expectTypeOf để kiểm tra type. Bạn kết hợp nó với @ts-expect-error để chắc rằng code sai sẽ bị chặn:
import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { OrderEventBus } from "./order-event-bus";
type Order = { id: string; total: number; status: "pending" | "paid" };
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
describe("keyof và indexed access", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("trả về đúng giá trị và đúng type", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("từ chối key không tồn tại", () => {
// @ts-expect-error - 'price' không phải key của Order
getProperty(order, "price");
});
it("event bus gọi handler với đúng payload", () => {
const bus = new OrderEventBus();
const handler = vi.fn();
bus.on("created", handler);
bus.emit("created", { orderId: "ORD-7", total: 99_000 });
expect(handler).toHaveBeenCalledWith({ orderId: "ORD-7", total: 99_000 });
});
});
Bạn chạy test bằng hai lệnh:
npx vitest run --typecheck
npx tsc --noEmit
Lưu ý:
expectTypeOfchỉ có tác dụng khi type được kiểm tra, qua--typecheckhoặctsc. Lúc runtime, nó gần như không làm gì. Nếu bạn chỉ chạyvitest runthường, test vẫn pass dù type sai.
Nếu ai đó xoá "price" khỏi danh sách lỗi, ví dụ thêm price vào Order, thì @ts-expect-error sẽ báo “Unused ‘@ts-expect-error’ directive”. Đây là tín hiệu tốt: test type của bạn đang bảo vệ hợp đồng API.
8. Lỗi hay gặp
Dùng biến làm index type. Với const key = "total", viết Order[key] sẽ báo lỗi:
error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
Cách sửa là viết Order[typeof key].
Gọi typeof trên biểu thức. typeof loadConfig() là lỗi cú pháp. typeof chỉ nhận identifier hoặc truy cập thuộc tính như typeof config.db. Với kết quả của hàm, bạn dùng ReturnType<typeof loadConfig>.
Nghĩ rằng Object.keys trả về keyof T. Thực tế nó trả về string[]:
for (const key of Object.keys(order)) {
// order[key]; // Lỗi: 'string' không index được 'Order'
}
TypeScript làm vậy có chủ đích. Type là “structural”, nên lúc runtime object có thể chứa nhiều thuộc tính hơn những gì type khai báo. Nếu bạn chắc chắn về dữ liệu, có thể ép kiểu Object.keys(order) as (keyof Order)[]. Nhưng hãy hiểu rằng đó là bạn đang cam kết thay compiler.
Quên as const. Thiếu nó thì (typeof ORDER_STATUSES)[number] chỉ còn là string, và mọi lợi ích của union literal biến mất mà không có lỗi nào báo ra.
Lưu ý: Đừng lạm dụng. Chuỗi như
Invoice["customer"]["address"]["city"]hợp lý khi bạn thật sự muốn type phụ thuộc vàoInvoice. NếuAddresslà một khái niệm độc lập, dùng ở nhiều nơi, hãy khai báo nó thành type có tên. Code sẽ dễ đọc hơn và thông báo lỗi cũng gọn hơn.
9. Kết luận
Ba công cụ trong bài làm ba việc khác nhau:
keyoflấy tập key của một type. Nó hữu ích nhất khi làm constraintK extends keyof T.typeofở vị trí type biến giá trị thành type. Khi đi cùngas const, nó giúp mảng và object hằng số trở thành nguồn sự thật duy nhất.- Indexed access type
T[K]lấy type của thuộc tính, phần tử mảng ([number]) hoặc cấu trúc lồng nhau.
Khi kết hợp lại, bạn có thể viết hàm generic và event bus mà compiler bắt được lỗi sai key hay sai payload ngay khi biên dịch. Bạn cũng không phải lặp lại cùng một danh sách ở nhiều nơi. Hãy nhớ bật strict, chạy tsc hoặc vitest --typecheck trong CI, và chỉ dùng chuỗi index dài khi type thật sự phụ thuộc vào nhau.