TypeScript 中的 keyof、typeof 和 indexed access types
1. 概述
当代码库越来越大时,你会遇到同样的一组 key 出现在多个地方的情况,比如订单状态,可能在 object、union type、函数参数中都出现过。改了一个地方而忘了另一个地方就会产生 bug。
TypeScript 有三个工具可以让你只声明一次,然后推导出其余部分。keyof 获取类型的 key 集合。typeof 从一个值获取类型。Indexed access type(T["key"])获取属性的类型。
读完这篇文章,你将学会如何组合这三个工具来编写类型安全的泛型函数,并构建一个类型严格的 event bus。你也会了解使用它们时常见的错误。
2. 准备
本文使用 TypeScript 5.x 和 Vitest 来编写测试。安装方式如下:
npm install --save-dev typescript vitest
你应该在 tsconfig.json 中启用 strict。如果不启用,许多关于 undefined 的错误将不会被报告:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. keyof 操作符
3.1. 基本用法
keyof T 返回一个 union,包含 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"; // 错误
最后一行无法编译:
error TS2322: Type '"price"' is not assignable to type 'keyof Order'.
你不必手动输入 union "id" | "total" | ...。当你向 Order 添加新属性时,OrderKey 会自动更新。
3.2. keyof 与 index signature
如果类型使用 index signature,结果可能会让你惊讶:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey 是 string | number 而不仅仅是 string。原因是在 JavaScript 中,obj[1] 和 obj["1"] 访问同一个属性。因此 TypeScript 允许 number 类型的 key。
3.3. keyof 在泛型中
keyof 与泛型约束结合时最有用:
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"); // 编译错误
这个函数做了几件事:
K extends keyof T限制了key,因此它只接受obj真实存在的属性名。- 返回类型
T[K]是一个 indexed access type,会在第 5 部分讲解。多亏了它,每次调用函数都会得到相应属性的正确类型。 - 用
"total"调用时得到number。用"status"调用时得到 union literal。这里没有任何any。
4. 类型上下文中的 typeof 操作符
JavaScript 原本就有 typeof 在运行时执行并返回像 "string" 或 "object" 这样的字符串。TypeScript 增加了第二个含义:当 typeof 放在类型位置时,typeof x 给你变量 x 的类型。
4.1. 从值获取类型
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" }));
结果:
{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }
这里默认值是唯一的真实来源。当你向 defaultCustomer 添加字段时,Customer 也会有该字段。
对于函数,你可以将 typeof 与内置的 utility type 结合:
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 与 typeof
默认情况下,TypeScript 会“扩宽” literal:"pending" 变成 string,数组变成 string[]。添加 as const 会保持 literal 并标记为 readonly:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
如果缺少 as const,typeof ORDER_STATUSES 只会是 string[],你会丢失所有 literal 信息。
4.3. 组合 keyof typeof
这是一种非常常见的写法,当你有一个常数 object 并想获取其 key 的 union 时:
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
读法是从内向外。typeof SHIPPING_FEES 获取 object 的类型,然后 keyof 获取该类型的 key。你不能写 keyof SHIPPING_FEES,因为 SHIPPING_FEES 是一个值而不是类型。
5. Indexed access types
5.1. 获取属性的类型
语法 T["key"] 像是属性访问,但发生在类型层面:
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
有三点需要注意:
- 可选属性(
note?)会得到包含undefined的类型。 - 传入 union key 会得到对应类型的 union。
T[keyof T]是获取 object 中“所有值的类型”的常用方式。
5.2. 获取数组元素的类型
用 number 作为 index 会给你元素的类型:
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
数组 ORDER_STATUSES 现在有两个用途。运行时,它是用来验证来自 API 的输入的列表。编译时,它生成 union type。你只需声明一次状态。
5.3. 嵌套访问
你可以连续 index 多次来深入结构:
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 直接从 Invoice 中提取,所以你不需要单独声明一个类型,然后还要保持两者同步。
6. 实际例子:类型严格的 event bus
现在把这三个工具组合在一起。目标是一个 event bus,其中每个 event 的 payload 被正确地类型检查:
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(`订单 ${p.orderId} 通过 ${p.method} 支付`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // 错误:缺少 'method'
// bus.emit("refunded", { orderId: "ORD-42" }); // 错误:event 不存在
输出:
订单 ORD-42 通过 card 支付
各部分的工作原理如下:
keyof OrderEvents将 event 名称限制为三个值"created" | "paid" | "cancelled"。OrderEvents[K]将 payload 与正确的 event 关联。在"paid"的 handler 中,参数p有method但没有reason。- Mapped type
[K in keyof OrderEvents]创建了一个存储 handler 的仓库,每个 event 有单独的列表。 - 要添加
refundedevent,你只需修改OrderEvents。其他地方会自动更新。
7. 用 Vitest 进行类型测试
Vitest 有 expectTypeOf 来检查类型。你可以将其与 @ts-expect-error 组合,以确保错误的代码会被阻止:
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 和 indexed access", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("返回正确的值和正确的类型", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("拒绝不存在的 key", () => {
// @ts-expect-error - 'price' 不是 Order 的 key
getProperty(order, "price");
});
it("event bus 用正确的 payload 调用 handler", () => {
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 });
});
});
你可以用两个命令运行测试:
npx vitest run --typecheck
npx tsc --noEmit
注意:
expectTypeOf只有在通过--typecheck或tsc检查类型时才有效。在运行时,它几乎什么都不做。如果你只是运行vitest run,即使类型错误,测试仍然会通过。
如果有人删除错误 "price",比如将 price 添加到 Order,那么 @ts-expect-error 会报告“Unused ‘@ts-expect-error’ directive”。这是一个好信号:你的类型测试正在保护 API 合约。
8. 常见错误
使用变量作为 index type。 对于 const key = "total",写 Order[key] 会报错:
error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
修复方法是写 Order[typeof key]。
在表达式上调用 typeof。 typeof loadConfig() 是语法错误。typeof 只接受 identifier 或属性访问,如 typeof config.db。对于函数的结果,你使用 ReturnType<typeof loadConfig>。
认为 Object.keys 返回 keyof T。 实际上它返回 string[]:
for (const key of Object.keys(order)) {
// order[key]; // 错误:'string' 无法索引 'Order'
}
TypeScript 这样做是有意的。因为类型是“结构化的”,所以运行时的 object 可能包含比类型声明更多的属性。如果你对数据很确定,可以强制转换 Object.keys(order) as (keyof Order)[]。但要明白这样做意味着你在代替编译器做承诺。
忘记 as const。 缺少它时,(typeof ORDER_STATUSES)[number] 只是 string,union literal 的所有好处都消失了,也不会有任何错误报告。
注意: 不要滥用。像
Invoice["customer"]["address"]["city"]这样的链式访问在你真的想要类型依赖于Invoice时是合理的。如果Address是一个独立的概念,在多个地方使用,应该将其声明为命名类型。代码会更易读,错误消息也会更简洁。
9. 结论
文章中的三个工具各自做不同的事情:
keyof获取类型的 key 集合。当用作约束K extends keyof T时最有用。typeof在类型位置将值转变为类型。与as const结合时,它使数组和常数 object 成为唯一的真实来源。- Indexed access type
T[K]获取属性的类型、数组元素([number])或嵌套结构。
结合在一起,你可以编写泛型函数和 event bus,编译器会在编译时捕捉错误的 key 或错误的 payload。你也不必在多个地方重复相同的列表。记住要启用 strict,在 CI 中运行 tsc 或 vitest --typecheck,并只在类型真正相互依赖时使用长链式 index。