Typescript 的这个误区,可能你也会踩

1,392 阅读5分钟

Hi,大家好,今天写这篇文章主要是和大家分享下自己在学习typescript过程中,经历过的一次理解误区。 ​

我在学习typescript主要是看官方文档,在看的时候觉得这些知识点都能理解,结合官方的例子,也觉得自己理解的都很到位了,但是当遇到具体的问题时,却无法辨析出该用哪些知识点来解决,不知道大家会不会也有同样的感觉。今天就和大家来分享我当时在联合类型方面的一个理解误区。 ​

image.png

问题背景

场景是这样的:

在一次业务开发中,有两种可能的业务场景,后端将这两种场景合并到一个接口去实现了。 在接口返回数据中,有id、name、detail三个字段,后端保证会给id赋值。 如果是A场景,则只有id,没有name和detail; 如果是B场景,则会给id、name和detail属性赋值。 出于数据安全性方面的考虑,当得到的值不是这两种时,前端需要借助typescript的能力给出错误提示⚠️ 场景A👇🏻:

{
  id:1
}

场景B👇🏻:

{
  id:1,
  name:'name',
  detail:'detail'
}

其实题意还是比较简单的,id肯定存在,name和detail要么同时有,要么同时没有。 所以后端返回的就是这两种类型之一👇🏻

// 场景A对应的类型
type A = {
  id:number;
}

// 场景B对应的类型
type B = {
  id:number;
  name:string;
  detail:string;
}

初步解法1

从题意中分析,name和detail都属于可选属性,则我们很快能写出这样的解法

type Res = {
  id:number;
  name?:string;
  detail?:string;
}

当然我们也很快地辨析出,这种方案是无法把题意中name和detail需要同时存在的关系表达出来的。 image.png

解法1

初步解法2

既然将单个属性置为可选的方案行不通,我们是不是可以再尝试下将整个对象置为“可选的”,要么是A,要么是B。结合从官方文档中学到的知识,联合类型似乎有这样的能力,由此我们可以写出如下的解法。

type Res = A | B

我们来试试看这样子能不能满足我们的要求。 image.png

解法2

image.png

通过这个例子可以发现,在obj1obj2中,name和detail是单独存在且未报错,这与题意不符,说明直接使用联合类型并不能帮我们解决这个问题。 当时笔者在这种解法行不通后,对联合类型的功能也产生了怀疑,边去重新翻了下官方文档。 引用一段官方文档对联合类型的解释👇🏻

Defining a Union Type

The first way to combine types you might see is a union type. A union type is a type formed from two or more other types, representing values that may be any one of those types. We refer to each of these types as the union’s members.

文档清楚地写明,联合类型由多种子类型组成。当一个值符合其中一种子类型时,就认为这个值是符合联合类型的。 包括官网例子里到的number|string这样的联合类型,符合这个类型的值,要么是number类型要么是string类型,所以我就直接把联合类型子类型之间的关系理解为或、互斥的关系。 但从**解法2 **的结论我们能发现,或、互斥的关系仅限于基础类型之间,当引用类型进行联合时,它的表现和基础类型相比是大相径庭的。当对引用类型进行联合操作时,它不会像基础类型联合起来那样表现出互斥的特性。

继续翻阅官方文档,其中也提到了值与类型之间的匹配关系:判断一个值是否匹配一个类型,用的是赋值兼容(Assignment compatibility)来判断。 也就是说,在上面的例子里,typescript在判断obj1obj2是否符合联合类型Res时,依据的就是obj1和obj2能否赋值给其中任意一个子类型:

  • obj1中的id和name,都是在类型A或者类型B中声明过的,属于已知属性,允许赋值;
  • obj2中的id和detail,都是在类型A或者类型B中声明过的,属于已知属性,允许赋值;

image.png

按这种赋值兼容的思路来解释 string|number, 其实也说得通,说明联合类型确实不是我所理解的或、互斥的关系。 ​ 官方也在issue上表示了目前不支持互斥。那么如果我们想要做到互斥的效果,就需要再引入另外一个知识点👇🏻。

never的秒用

我们对any、string、number、boolean这些类型都很熟悉,但其实never也是很重要的一个类型。 在类型声明中,如果一个属性的类型是never,代表这个属性是不能被赋值的,否则就会出错。 这个特性可以拿来解决我们的问题。 ​

最终解法

在社区搜索后,得出以下一段用来表示或、互斥关系的代码。

interface A {
  id: number;
}
interface B {
  id: number;
  name: number;
  detail: number;
}
type Without<T, U> = {
  [p in Exclude<keyof T, keyof U>]?: never;
};
type XOR = (Without<A, B> & B) | (Without<B, A> & A);

完整例子

可以看到,利用never特性,经过XOR的转化后,最终满足了我们的要求。 ​

image.png

总结

  1. 构成联合类型的几种子类型之间,并不是互斥关系
  2. 在特定场景下,never也是一种有效的类型限制手段
  3. 消化吸收知识点后,仍要保持辩证的态度看待它,用实践来检验正确性
  4. 在老版本的typescript文档中,其实还有fresh object literal type widen type 等概念,由于在新版文档中已经没有这部分内容,就不再深入研究了。
  5. 最核心的一点就是,联合类型的子类型之间不是互斥关系,想要达到互斥,还需要依赖其他技术(never)来做一次转化

参考资料