《TypeScript编程入门》9.1 keyof·typeof 与索引访问类型

本节讲解 TypeScript 类型编程的三块基石:用 keyof 取出对象类型的全部键名,用 typeof 从运行时值反向推导类型,用索引访问类型 T[K] 精确取出某个属性或数组元素的类型。三者组合出的 keyof typeof 是工程中最常见的写法,能让类型与实现保持单一数据源。本节配有可运行示例与真实报错信息,读完你能读懂类型库中的索引写法,并为映射类型与条件类型打下基础。

本节目标:读完这一节,你能说清 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 anystring | 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) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes