前端知库

keyof、typeof、索引访问类型和索引签名分别做什么?

keyof 取出对象类型所有键组成的联合类型;类型位置的 typeof 从一个变量拿到它的类型;索引访问类型 T[K] 按键查出属性的类型,T[number] 能取数组元素类型。三者常组合成 keyof typeof obj 和 (typeof arr)[number]。索引签名 [key: string]: V 描述键名事先不知道的字典,所有具名属性都要符合它,读任意键默认被当成 V,开启 noUncheckedIndexedAccess 才会加上 undefined。

参考答案

简答

比如有一个配置对象 config = { port: 3000, host: 'localhost' },想写一个函数只接受它的键名。typeof config 能从这个变量拿到它的类型,keyof 再把类型的所有键取出来,keyof typeof config 就是 'port' | 'host'。

三个工具各管一件事。keyof T 得到 T 所有键组成的联合类型。类型位置上的 typeof x 得到变量 x 的类型,它和 JavaScript 里运行时的 typeof 不是一回事,而且只能用在变量名或它的属性上。索引访问类型 T[K] 按键查属性的类型,比如 Person['age'] 是 number;K 可以是联合类型,T[number] 还能取出数组元素的类型。

组合起来最常见的用途是“从值推出类型”,让值和类型只维护一份。比如 const roles = ['admin', 'editor'] as const,再写 type Role = (typeof roles)[number],就得到 'admin' | 'editor'。泛型里写 K extends keyof T,返回 T[K],就能做到传错键名报错、返回值类型跟着键走。

索引签名 [key: string]: number 用来描述键名事先不知道的字典。要注意两点:一是有了字符串索引签名后,所有具名属性的类型都必须符合它;二是读一个不存在的键时,默认类型仍然是 number,不会提醒可能是 undefined,要开启 noUncheckedIndexedAccess 才会。

keyof 和 typeof 组合

下面的代码都用 tsc --strict 验证过(TypeScript 7.0.2)。

TypeScript
type Point = { x: number; y: number };
type P = keyof Point; // 'x' | 'y'

const config = { port: 3000, host: 'localhost' };
type Config = typeof config; // { port: number; host: string }
type ConfigKey = keyof typeof config; // 'port' | 'host'

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const port = getValue(config, 'port');
port.toFixed();
// @ts-expect-error 'prot' 不是 config 的键
getValue(config, 'prot');

为什么不能直接写 keyof config?因为 keyof 只接受类型,config 是一个值。要先用 typeof 把值变成类型,再交给 keyof。

getValue 的返回值为什么是 number?调用时 K 被推断成 'port',返回类型 T[K] 就是 config 类型里 port 的类型。换成 'host',返回的就是 string。这样写,键名和返回值类型都由编译器检查。

类型位置的 typeof 有个限制:Handbook 说明它只能用在标识符(变量名)或它们的属性上。typeof getValue(config, 'port') 这种对函数调用结果取类型的写法会报语法错误,要拿返回值类型可以用 ReturnType<typeof getValue>。

索引访问类型

TypeScript
type Person = { age: number; name: string; alive: boolean };
type Age = Person['age']; // number
type NameOrAge = Person['age' | 'name']; // number | string
type AnyValue = Person[keyof Person]; // number | string | boolean

const roles = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof roles)[number]; // 'admin' | 'editor' | 'viewer'

Person[keyof Person] 是常见写法,表示“所有属性值类型的联合”。数组用 number 去索引,是因为数组的下标是数字,所以 T[number] 就是元素类型。roles 加了 as const 才会得到字面量联合,否则元素会被推断成 string,as const 的细节见《as const、类型注解和 satisfies 有什么不同?》。

方括号里只能写类型。Handbook 举过例子,const key = 'age' 之后写 Person[key] 会报错,因为 key 是值;改成 type key = 'age' 就可以。

索引签名描述字典

TypeScript
type Mapish = { [k: string]: boolean };
type M = keyof Mapish; // string | number

interface Dictionary {
  [key: string]: number;
  length: number;
}
interface Bad {
  [key: string]: number;
  // @ts-expect-error 2411:name 的类型必须符合索引签名
  name: string;
}

const scores: Record<string, number> = { ada: 90 };
const s = scores.bob; // 默认是 number,开启 noUncheckedIndexedAccess 后是 number | undefined

keyof Mapish 为什么是 string | number?Handbook 的解释是,JavaScript 对象的键总会被转成字符串,obj[0] 和 obj['0'] 是同一个属性,所以字符串索引签名也接受数字键。

Bad 为什么报错?字符串索引签名表示任何字符串键读出来都是 number,obj.name 也等于 obj['name'],所以 name 不能是 string。如果确实要混合,可以把签名写成 number | string。

scores.bob 根本不存在,类型却是 number,直接调用 s.toFixed() 能编译通过,运行时会报错。开启 noUncheckedIndexedAccess 后,同样的代码会提示 's' is possibly 'undefined'。这个选项的取舍见《strict、noUncheckedIndexedAccess 和 target 如何配置?》。

常见追问

Record<string, number> 和 { [key: string]: number } 有区别吗?

用字符串作键时效果一样,Record<K, V> 是内置的工具类型。区别在于 Record 的键可以是有限的联合,比如 Record<'a' | 'b', number> 要求两个键都必须有;索引签名的键只能是 string、number、symbol、模板字符串模式或它们组成的联合。映射类型见《映射类型与 Partial、Pick、Omit 如何用于接口建模?》。

类型里的 typeof 和运行时的 typeof 怎么区分?

看它出现在哪里。写在类型注解、type 定义这些类型位置上,是 TypeScript 的类型运算,编译后会被删掉。写在 if (typeof x === 'string') 这类表达式里,是 JavaScript 运行时的运算符,返回一个字符串,TypeScript 还会根据它缩小类型。