TypeScript 搞对象:Object vs object vs {}

1,560 阅读6分钟

00. 长话短说

大家好,我是大家的林语冰~ 👨‍💻

TS 支持 3 种通用对象类型,类型系统允许它们互相赋值,但一般不推荐直接使用。

op-obj.png

在本文中,我们会结合 TS 源码,深度学习这 3 种通用对象类型的设计动机和技术细节:

  1. 为什么原始值允许赋值给大写的 Object 类型
  2. 为什么 TS 团队又引入了小写的 object 类型
  3. 如何理解 {} 字面量类型

01. Object 类型

大写的 Object 类型和 JS 内置的 Object() 函数同名,所以比较迷惑。

01-upper.png

现实开发中,我们只需要区分编程动机,Object 是作为类型注解(TS 接口),还是作为函数调用(JS 函数),就不会混淆。

⛔ 注意,虽然两者的拼写一模一样,但 Object() 其实不是 Object 类型

我们可以结合 TS 源码来理解:

02-ctor.png

可以看到,Object() 函数的类型其实是 ObjectConstructor 接口,而实例对象 o 和原型对象 prototype 才是 Object 类型。

我们继续阅读 TS 源码中 Object 接口的定义:

03-proto.png

可以看到,Object 接口定义了 Object.prototype 原型对象上能被继承的所有 公共属性,比如 ({}).__proto__.toString() 方法等。

这意味着,几乎 所有 Object() 构造函数的实例对象和子类实例都允许赋值给 Object

⛔ 注意,JS 中存在一种“裸对象”极端情况 —— 无原型对象

04-naked.png

可以看到,虽然无原型对象不是 Object() 的实例,但它也允许赋值给 Object 类型。

从技术上讲,TS 允许所有 typeof x === 'object' 断言为真的对象赋值给 Object 类型(typeof null 除外),它们大都能够上溯原型链访问 Object.prototype

另一点比较反直觉的是,TS 还允许 非空原始值 赋值给 Object 类型。

这是因为,作为动态类型语言,JS 允许 非空原始值 像对象一样直接调用方法,这通过 包装对象 来实现:

  1. 当原始值调用方法时,比如字符串 'str'.slice(),JS 引擎会在底层将 'str' 自动装箱Object('str'),将原始值强转为 包装对象
  2. 'str'.slice() 方法调用完成后,JS 引擎又会将包装对象 自动拆箱,强转为原始值。

因此,为了兼容 JS 的 装箱/拆箱 机制,TS 就将静态类型系统的设计对齐 包装对象 的运行时行为:

05-box.png

上述代码中,str 手动注解为 Object 类型后,str 的类型既不是 string,也不是 String

由于 Object 接口并没有定义 ''.slice() 方法,所以 (str as Object).slice() 会报错,需要我们使用类型断言手动修正。

⛔ 注意,TS 团队不推荐使用 Object 类型,永远禁止滥用大写的 StringObject 等包装对象类型

同理可得,虽然 nullundefined 也是 JS 原始值,但它们没有对应的包装对象,所以禁止赋值给 Object 类型。

06-nullish.png

从技术上讲,Object 接口可以理解为 anyunknown 之外的第 3 种 顶端类型,除了 nullundefined 之外的所有类型都是 Object 类型的子类型。

换而言之,TS 允许任意 非空值 赋值给 Object 类型。

02. object 类型

根据《ECMAScript 语言规范》,JS 的数据类型可以分为 2 种对立类型:

07-object.png

可以看见,JS 的原始类型和对象类型(非原始类型)构成一个完整的类型全集,它们互补互斥。

但由于 TS 的 Object 接口也兼容原始类型,TS 早期缺少可以严格表示非原始类型的对象类型。

因此,TS 2.2 引入了小写的 object 类型,与 Object 接口不同,它专门用于表示 非原始类型

08-non-primitive.png

可以看到,TS 禁止原始值赋值给 object 类型。此外,object禁止赋值 给大多数对象字面量类型。

⛔ 注意,object 类型 能且仅能 赋值给下列 4 种类型:

  1. any 类型
  2. unknown 类型
  3. Object 接口
  4. {} 空对象字面量类型

虽然 object 类型比 Object 接口更严格,但它并非没有用武之地,我们可以偷师 TS 源码的最佳实践:

09-create.png

根据《ECMAScript 语言规范》,Object.create() 的方法签名严格要求第一个参数必须是对象或者 null

TS 2.2 之前,TS 能且仅能 使用万能的 any 作为次优解,副作用是会丢失部分类型细节。

TS 2.2 之后,这种场景下,使用 object 类型是最优解,我们可以得到精准的类型提示。

03. {} 字面量类型

{} 空对象字面量类型和 {} 空对象字面量一模一样,它是对象字面量类型的特例,表示没有任何自有属性的对象。

10-empty.png

可以看到,{} 空对象字面量类型的赋值兼容性基本和 Object 接口一致。

由于 {} 空对象字面量类型 有且仅有 继承自 Object.prototype 的原型属性,所以它偶尔可以用作 Object 接口的备胎。

04. 对象类型相对论

从技术上讲,Object 类型和 {} 字面量类型都兼容原始类型,所以它们表面上比 object 类型更宽松。

但当对象重写继承自 Object.prototype 的属性签名时,Object 类型会更严格:

11-prop.png

根据《ECMAScript 语言规范》,Object.prototype.toString() 方法签名 能且仅能 返回 string 类型。

因此,当目标对象的属性签名和 Object.prototype 的类型不兼容时,TS 就会报错。

object 类型和 {} 字面量类型并没有明确定义目标对象的属性签名,所以 TS 不会检查类型兼容性。

05. 对象类型基本法

刚入门 TS 时,我们容易滥用或误用通用对象类型:

12-general.png

可以看到,通用对象类型没有明确目标对象的属性签名,所以 VSCode 等 IDE 可能没有拼写提示或代码补全,TS 编译时也会报错,这就完美规避了静态类型的所有优势。

⛔ 注意,滥用通用对象类型就像 写了一句无效的注释,对特定对象使用通用的类型注解没有实际意义,我们要有意规避这种 反模式

相反,我们推荐下列 3 中常见的替代方案:

13-case.png

高潮总结 👇

TS 支持 3 种通用对象类型,一般不推荐直接使用,它们的设计动机有所不同:

  • Object 类型 - 它表示 Object.prototype 的接口,且兼容原始类型
  • object 类型 - 它表示非原始类型,禁止原始值赋值
  • {} 空对象字面量类型 - 它和 Object 接口大同小异,但不检查对象属性签名的兼容性

op-set.png

参考文献

  1. TS GitHubgithub.com/microsoft/T…
  2. Do's and Don'tswww.typescriptlang.org/docs/handbo…
  3. TS@2.2www.typescriptlang.org/docs/handbo…

粉丝互动 😍

👉 本期话题是:你是否遭遇过误用 TS 通用对象类型而导致的 bug?🤣

读完了的小伙伴可以在本文下方留言互动,或者友情转发。👍

我是大家的林语冰 👨‍💻,欢迎持续关注我,深度学习前端进阶的技术细节。谢谢大家的点赞和转发,我们下期再见,掰掰~ 👻