TypeScript 或许没有我们想象中的那么聪明。
背景
被一个问题困扰已久,看下面一段 TypeScript 代码
const tags = []
if (flagA) {
tags.push({ text: 'textA', color: 'colorA' })
}
这段代码很好理解,我们根据 flagA 真假来填充 tags 数组的内容。
但如果有很多条件判断呢?比如:
const tags = []
if (flagA) {
tags.push({ text: 'textA', color: 'colorA' })
}
if (flagB) {
tags.push({ text: 'textB', color: 'colorB' })
}
if (flagC) {
tags.push({ text: 'textC', color: 'colorC' })
}
if (flagD) {
tags.push({ text: 'textD', color: 'colorD' })
}
这段代码是不是看着头开始痛起来了,所以我们通常会这么写
// flagA?: boolean
type Tag = {
text: string
color: string
}
const tags = [
flagA && { text: "textA", color: "colorA" },
flagB && { text: "textB", color: "colorB" }
].filter(Boolean)
其实实质是利用 && 运算符懒惰特性,然后借助 filter 函数过滤掉无效值。
类型丢失
好了进入正题,问题来了上面这种写法得到的 tags 的类型是什么呢?
从逻辑层面考虑 tags 的类型应该为 Tag[],可是遗憾的是 TypeScript 并没有这样判断

一开始我也是百思不解,理论上经过 filter 去掉 false 和 undefined 的情况,那不是只剩下 Tag 类型了么?为什么两种假值还存在类型中呢?
filter 是 Array 原型上的函数,所以我们可以研究一下对应的类型文件:

可以看到接口 Array 存在范型 T,也就是说我们在 new Array 时就会确定 T 的类型,我们上述代码中的 [...] 其实也是 new Array 的一种语法糖
const tags = new Array([
flagA && { text: "textA", color: "colorA" },
flagB && { text: "textB", color: "colorB" }
]).filter(Boolean)
也就是说我们可以确定此时的 T = false | undefined | Tag
我们再找到 filter 函数的类型

可以看到 filter 有两种形式,如果我们主动声明范型 S,那么最终输出结果就是 S[],如果没有那么 filter 就只会返回最初实例化 Array 时记录的类型。
换句话说 filter 返回的类型和传入的回调函数是什么没有半毛线关系,因为 filter 无法得知回调函数 predicate 内部到底过滤了啥、留下了啥、返回了啥。
filter 不会根据 predicate 创造类型,filter 只是类型的搬运工!
那么就没救了吗?不,我们要充分相信 TypeScript 的设计,再回过头来看第一种声明,我们尝试在开头声明范型

似乎返回类型正确了,但我们发现 Boolean 函数下出现了刺眼的红色波浪线,铺垫了这么多终于要讲到本文的关键点。
类型谓语 is
我们仔细观察一下 filter 的第一种声明中范型 S 不仅仅出现在 <S extends T> 中,似乎还出现在 predicate 中,(value: T) => value is S 。
这是个啥?从来没见过,查阅一下官方文档的 is 关键词,它对 is 的使用场景做了描述:
看完说明和用例我就恍然大悟了,其实如果经常写库,is 是一个非常常见的关键词,我们可以看一下 lodash 的声明文件,比如比较常用的 isNil,我们可以认为这是一种断言函数

所以借助 is 我们可以非常优雅的完成类型范围缩小。
type Tag = {
text: string
color: string
}
const tags = [
flagA && { text: "textA", color: "colorA" },
flagB && { text: "textB", color: "colorB" }
].filter((i): i is Tag => Boolean(i))
这段代码得到的变量 tags 的类型就是唯一确定的 Tag[] 。
回过头来,我们再思考一下在 filter 中如果我们使用了范型 S,就必须有对应的断言函数呢?其实这就是 TypeScript 的严谨性,我们可以发现范型 S,并不能随便指定它存在两个约束条件:
仔细想一想如果失去了其中一种约束,这时是不是就等于
// filter<S>(): S[]
const tags = [...].filter<any>(callback)
然后我们再换一种写法
const tags = [...].filter(callback) as any[]
那么 TypeScript 类型约束就会形同虚设。
值得深思的是,TypeScript 被称为 JavaScript 的超集。而事实上 TypeScript 仅仅存活在 TypeScript Server 或者 TSC 编译时中,所谓的类型约束。当类型与 JavaScript 逻辑产生耦合的时候就可能需要使用强制声明来弥补 TypeScript 的不足。
Based Type Guards: this
我们已经知道了 is 具有断言的作用,当然这是针对使用函数判别某个变量时的场景。那么如果是在 class 内呢?先举个场景例子
type Form = AForm | BForm
class Common {
form: Form
constructor(f: Form) {
this.form = f
}
}
class AForm {
common: Common
type: string = 'A'
constructor() {
this.common = new Common(this)
}
echoA() {}
}
class BForm {
common: Common
type: string = 'B'
constructor() {
this.common = new Common(this)
}
echoB() {}
}
我们在不同的业务场景存在不同的表单 A 和表单 B,但作为表单总存在公共的内容,我们使用 Common 数据结构来表达它,当我们需要在一个公共组件中使用实例 form 时,我们需要判断表单是 A 还是 B 来承接不同内容。
例如我们使用一个叫 echoCommonContext(common: Common) 显示公共部分,以及两种表单的特殊部分。
理所应当的,我们会使用 common.form.type === 'A' 来判别传入的 common 的父类到底是 A 还是 B 来决定调用 echoA() 还是 echoB(),从 JavaScript 的角度一切都没有问题。
可是遗憾的是 TypeScript 并不能从 === 推断出 common.form 的类型,因为在他看来这是完全不相关的逻辑:

这种场景和我上述讲的 filter 有些相似,但并不是完全没有解决办法,我们可以借助 enum 来告诉 TypeScript common.form.type 不是仅仅是一个字符串,他还有决定上层类型的作用
enum FormType {
A = 'A',
B = 'B',
}
class AForm {
type: FormType.A = FormType.A
}
class BForm {
type: FormType.B = FormType.B
}
function echoCommonContext(common: Common) {
if (common.form.type === FormType.A) {
common.form.echoA()
} else {
common.form.echoB()
}
}
这会使得 aForm.type 被永久的打上 FormType.A 的烙印,他不能是别的任何值,哪怕是看起来完全相同的值字符串 "A"。
我们还有更优雅的解决方案,我们可以写一个函数专门用于判断 AForm 类型尝试借助上文讲的 is 去断言类型
const isAForm = (form: Form): form is AForm => form.type === 'A'
这种方式非常完美的解决了类型断言问题,还不需要使用枚举这种限制巨大的类型

本着程序员的直觉,我们可以发现 isAForm 可能在未来不仅仅被使用在一个地方,他完全有资格成为 Common 的一个属性,所以我们把他放到 Common 内部。
并且放到内部还有个好处,isAForm 不再需要额外的传参,他完全可以访问到实例内部 form 的地址。
然而问题又来了, is 是对传入参数的类型断言,现在我们取消了传参该如何对 form 进行类型定义呢?

于是乎 this is 闪亮登场,TypeScript 官方的文档很友善的把 this is 的文档链接贴在 is 文档下方,没错哦,他料到了你用了 is 这种骚操作之后肯定会遇到这个问题。
官方的解释很直白,这玩意是专为 class 准备的,有了它我们就可以这样写:
class Common {
form: Form
constructor(f: Form) {
this.form = f
}
isAForm(): this is Common & { form: AForm } {
return this.form.type === 'A'
}
}
值得一提的是,即使使用这种方式我们也不能改变最初被赋值声明类型的子属性

可以看到在 common.isAForm() 之前,我们如果已经赋值 const { form } = common,那么这个 form 仍然无法被 TypeScript确定类型。