TypeScript object vs record vs {}

最后更新于

TypeScript 中存在三种可以用来描述对象的类型:objectRecord<string, any>{},该用哪种?下面就来看看。

{} 空的形状

首先我们知道 {} 是一个 interface,它负责匹配形状,而一个空的形状约等于 any ——

let a: {} = 1 // ok

实际上,它保证右侧的值是可以按对象形式(也就是下标形式)访问的。也就是说 nullundefined 不在此列(因为他们直接 .foo 会炸)。

let a: {} = null // error

此外,注意到对象都继承有 Object.prototype,所以我们可以调用这些方法而不爆炸:

let a: {} = 1
a.toString() // ok

上面的说法有个问题,Object.create(null) 可以不继承那些方法,但是 Object.create() 的类型返回 any,所以 TypeScript 实际检查不出下面代码的错误:

let a: {} = Object.create(null)
a.toString() // types ok, but runtime error

object 非基本类型以外的所有

可以阅读 TypeScript 2.2 的更新说明,就是 {} 去掉 number 之类的基本类型。

实际上,除了标准库里会有一些接口必须传入非基本类型对象(如 map.set(object,value))外,日常操作几乎用不着这个玩意儿。

Object 所有对象都继承的形状

Object(大写 O)是定义在 libes*.d.ts 里的 interface,它是所有对象类型最终继承的形状,所以它上面的方法可以从任意对象上点出来使用。

除了用于扩展标准库以外,日常操作几乎用不着这个玩意儿。

// 可能的用途
// 模块上下文需要 declare global,脚本上下文不需要
declare global {
  interface Object {
    foo(): number
  }
}
Object.prototype.foo = function foo() {
  return 42
}

Record<string, any> 传统字典,或者不如 {}

Map(es6)发明出来之前,JS 里只能使用对象来模拟使用字典(实际上 v8 会检测这种用法并在底层切到一个真的字典实现)。因此 Record<K, V> 可以用于标记这种用法。

另一种用法是,当你在玩一些类型体操,操作用户传入或者 infer 出来的对象类型时,需要先用 extends 来限制传进来的确实是对象类型,你可能会在哪里看到这种写法:

function foo<T extends Record<string, any>>(obj: T, key: keyof T) {}

简单解释一下 extends,这个关键字限制传进来的类型必须被右侧的类型包含,然后可以对原类型做后续处理(如 keyof 和别的体操)。

显然,这个例子里我们限制第一个参数大概需要是个对象,然后第二个参数会推导出第一个参数的下标。那么用 {} 可以吗?可以!

type Foo<T extends {}> = keyof T
// type A = 'a' | 'b'
type A = Foo<{ a: 1; b: 2 }>
// type B = 'toString' | 'toFixed' | 'toExponential' | 'toPrecision' | 'valueOf' | 'toLocaleString'
type B = Foo<1>

这不对,你可能想这么说。这一眼看上去不符合你对用户的用法的期待,但是类型和运行时上都是允许的。

foo(1, 'toString'); // ok
(1)['toString'] // ok

我建议所有人都把 extends Record<string, any> 换成 extends {},因为

typescript-eslint 历史遗留问题

typescript-eslint 默认禁止你使用 Object{},前者没什么问题,但 {} 实际上是很有用的类型。这纯粹是因为 TypeScript 以前的 {} 没那么好用。

你可以添加以下规则来覆盖它对 {} 的限制:

{
  "@typescript-eslint/ban-types": ["error", {
    "types": { "{}": false },
    "extendDefaults": true
  }]
}