本节目标:读完这一节,你能说清
keyof、typeof、索引访问类型这三个「类型运算符」各自解决什么问题;能手写一个带完整类型安全的get(obj, key)函数;能解释keyof typeof config为什么会得到一串字面量联合;并且能看懂Type '"xxx"' cannot be used to index type这类报错到底在提示什么。
9.1 keyof·typeof 与索引访问类型
前面八章里,我们一直在「声明类型」:写 interface、写泛型、写联合类型。从这一节开始,我们要换一个视角——把类型本身当作数据来操作。
在 7.1 联合类型与字面量类型
中你已经见过 "id" | "name" 这样的字面量联合。当时它是手写出来的;而在真实工程里,键名往往有几十个,手写既容易漏也容易和实现脱节。keyof、typeof、T[K] 这三个运算符的价值,正是从已有的类型或值里自动推导出这些联合类型,让类型与实现只有一个数据源。
为什么需要「类型运算」
先看一个典型的脆弱写法:
interface User {
id: number;
name: string;
email: string;
}
// ❌ 手写键名:一旦 User 增加字段,这里不会报错,但已经不同步了
type UserKey = "id" | "name" | "email";
如果某天 User 删掉了 email、改名为 mail,UserKey 仍然包含 "email",编译不会提示任何问题,错误会被推迟到运行时。
换成运算符写法:
type UserKey = keyof User;
// 等价于 "id" | "name" | "email",且永远与 User 保持同步
这就是类型运算的核心收益:单一数据源(single source of truth)。类型定义改了,所有派生类型自动跟着改。
keyof:把对象的键变成联合类型
keyof T 的结果是「T 上所有可用键名组成的联合类型」。
interface User {
id: number;
name: string;
email: string;
}
type K1 = keyof User; // "id" | "name" | "email"
type K2 = keyof { a: 1; b: 2 }; // "a" | "b"
type K3 = keyof string; // string 的全部方法名,如 "charAt" | "slice" | ...
几个容易踩坑的边界情况,值得单独记住:
| 写法 | 结果 | 说明 |
|---|---|---|
keyof { a: 1; b: 2 } | "a" | "b" | 普通对象取字面量键 |
keyof { [k: string]: number } | string | number | 字符串索引签名会额外带上 number |
keyof any | string | number | symbol | 任意合法属性键的类型 |
keyof {} | never | 没有任何键 |
keyof (A | B) | keyof A & keyof B | 联合类型的键取交集 |
最后一行是最反直觉的一条。原因是:一个值如果可能是 A 也可能是 B,那么只有两边都存在的键才是安全的。这个「联合取交、交叉取并」的规律在 5.2 type 别名、联合与交叉
里我们讨论过集合视角,这里算是它在类型运算上的直接体现。
interface A { a: string; common: number }
interface B { b: string; common: number }
type KeyOfUnion = keyof (A | B); // "common"
type KeyOfIntersection = keyof (A & B); // "a" | "common" | "b"
typeof:从值反推类型
typeof 在 TypeScript 里是两个东西,必须分清:
- 写在表达式位置的
typeof x:JavaScript 原生的运行时运算符,返回"string"、"object"这类字符串。 - 写在类型位置的
typeof x:TypeScript 独有的「类型查询」运算符,返回x的类型。
本节讲的都是后者。它让我们不必为已有的对象再手写一遍 interface:
const config = {
host: "localhost",
port: 8080,
tls: false,
};
type Config = typeof config;
// 推导为:
// {
// host: string;
// port: number;
// tls: boolean;
// }
注意这里的推导结果里 host 是 string 而不是 "localhost"。原因是 const 只让变量本身不可重新赋值,对象属性仍然可变,所以属性类型被「拓宽(widening)」了。如果你想要字面量类型,需要 as const:
const config2 = {
host: "localhost",
port: 8080,
} as const;
type Config2 = typeof config2;
// {
// readonly host: "localhost";
// readonly port: 8080;
// }
typeof 也能作用于函数,得到完整的函数签名:
function add(a: number, b: number): number {
return a + b;
}
type Add = typeof add; // (a: number, b: number) => number
还有一条限制要记住:typeof 后面只能跟值,不能跟类型。typeof User(User 是 interface)会直接报错:
'User' only refers to a type, but is being used as a value here.
索引访问类型 T[K]
索引访问类型(indexed access type)用方括号从一个类型里「取出」某一部分,语法和访问对象属性一模一样,只是发生在类型层面。
interface User {
id: number;
name: string;
email: string;
}
type IdType = User["id"]; // number
type NameType = User["name"]; // string
type AnyValue = User[keyof User]; // number | string
把 keyof 和索引访问结合起来,就得到「所有属性值类型」的联合——上面第三行的 User[keyof User] 正是这个意思。
索引访问对数组和元组同样有效,而 [number] 是最常用的写法:
type StringArray = string[];
type Elem = StringArray[number]; // string
type Tuple = [string, number, boolean];
type First = Tuple[0]; // string
type Second = Tuple[1]; // number
type All = Tuple[number]; // string | number | boolean
用 [number] 从一个数组类型里取出元素类型,是后面处理 API 响应、事件负载时反复用到的技巧。等价的写法还有 T[number] 与 Array<T>[number],效果一致。
组合技:keyof typeof
把两个运算符叠起来,就能「从一个运行时对象直接派生出键名联合」:
const palette = {
primary: "#0d6efd",
danger: "#dc3545",
success: "#198754",
} as const;
type PaletteKey = keyof typeof palette;
// "primary" | "danger" | "success"
type PaletteColor = (typeof palette)[PaletteKey];
// "#0d6efd" | "#dc3545" | "#198754"
注意第二行括号的位置:(typeof palette)[PaletteKey]。如果写成 typeof palette[PaletteKey],TypeScript 会把它解析成 typeof (palette[PaletteKey]),语义完全不同,而且会报错。typeof 的优先级低于索引访问,这是新手最容易写错的地方之一。
keyof typeof 在工程中出现的频率极高,典型场景是「配置常量 + 以其键为参数」:
function setTheme(key: PaletteKey) {
document.body.style.color = palette[key];
}
setTheme("primary"); // ✅
setTheme("warning"); // ❌ Argument of type '"warning"' is not assignable to parameter of type '"primary" | "danger" | "success"'.
改动 palette 时,PaletteKey 自动更新,所有调用点会被编译器逐一检查。
一个真实可用的 get 函数
把三个运算符合起来,可以写出一个既通用又完全类型安全的取值函数。这也是很多状态管理、ORM 库内部的核心签名:
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Ada", active: true };
const id = get(user, "id"); // number
const name = get(user, "name"); // string
get(user, "email");
// ❌ Argument of type '"email"' is not assignable to parameter of type '"id" | "name" | "active"'.
这里的 K extends keyof T 就是 8.1 泛型约束(extends)
讲过的约束,只不过约束的来源变成了 keyof。返回值类型 T[K] 会随传入的 key 自动收窄,这就是「用类型运算换来精确推断」的典型收益。
如果 obj 的键可能不存在,可以加一层可选处理:
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const picked = pick(user, ["id", "name"]);
// { id: number; name: string }
Pick 是内置工具类型,我们会在 10.1 内置工具类型全解
中逐个拆解它的实现——它的内部正是用 keyof 加索引访问写出来的。
常见坑与报错
坑一:键名拼错。 这是最高频的错误:
Property 'emial' does not exist on type 'User'. Did you mean 'email'?
编译器甚至会给出拼写建议,这是类型运算带来的额外好处。
坑二:把 keyof 用在值上。 keyof 只能作用于类型:
'user' refers to a value, but is being used as a type here. Did you mean 'typeof user'?
报错信息里已经把正确写法告诉你了。
坑三:索引越界。
Type '"email"' cannot be used to index type '{ id: number; name: string }'.
坑四:误以为 typeof 能拿到运行时信息。 再强调一次:类型会被完全擦除。typeof 只是编译期的语法糖,运行时你无法用它判断一个变量「是不是某种形状」——那属于 13.1 类型擦除带来的运行时盲区
讨论的范畴,需要借助 Zod 之类的校验库。
坑五:对 any 用 keyof。 keyof any 得到 string | number | symbol,这往往意味着某个上游类型漏标注了,应该回头修那个 any,而不是在这里绕过去。
小结前的一个实践建议
keyof 与索引访问是「读」类型;下一节的映射类型是「写」类型。在写任何手动的键名联合之前,先问自己一句:这个联合能不能从已有类型里推出来?绝大多数情况下答案是肯定的。当你习惯了这个思路,就会发现自己手写的 type X = "a" | "b" | "c" 越来越少,代码也随之内聚起来。
延伸阅读:本节把「读类型」的三个运算符讲完了,紧接着的 9.2 映射类型与键重映射(as) 会展示如何用它们批量生成新类型;如果你想看类型运算符在真实项目里的更多案例,既有专题文章 /typescript-type-level-programming/ 与 /typescript-advanced-types/ 可作为补充。
小结
keyof T取出T的所有键名,得到字面量联合;联合类型取键是交集,交叉类型取键是并集。typeof value是类型层面的查询运算符,从运行时值反推类型;const变量得到字面量类型,对象属性会被拓宽,需要as const才能保留字面量。- 索引访问类型
T[K]从类型中取出属性类型;T[number]取出数组或元组元素类型,T[keyof T]取出所有属性值类型的联合。 keyof typeof obj是从常量对象派生键名联合的标准写法;注意typeof优先级低于[],组合时要加括号。K extends keyof T+ 返回T[K]是通用取值函数的标准签名,也是类型库的常见形态。
下一节我们把视角从「读取」转向「生成」:映射类型能够遍历一个类型的所有键,批量生产出新类型——它是 Partial、Readonly、Pick 这些工具类型的共同底层机制。
阅读导航:上一节:8.3 泛型在接口·类·函数中的协同 · 下一节:9.2 映射类型与键重映射(as) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。