TS类型体操 之 extends,Equal,Alike 使用场景和实现对比

7/4/2022 TS

# TS 类型体操 之 extends,Equal,Alike 使用场景和实现对比

在程序中,判断相等是一个很重要的内容,在 TS 中判断相等(其实是属于 xxx 范围) 用的是 extends。只用 extends 自然是不够,所以有很多工具类有一些很微妙的技巧来实现严格相等的功能

下面会讲到几个工具类 EqualAlike。这 2 个并不是 TS 的官方实现,而且在 TS 体操练习的仓库里面大神封装的工具类

# extends 的作用

先回顾一下 extends 的作用和多数的应用场景


// 🌰 - 1
type ID = string | number
type TestID = ID extends string ? true : false  // false
type TestID2 = string extends ID ? true : false // true


// 🌰 - 2
type UnionType = string | number | boolean
type testUnion = string extends UnionType ? true : false // true
type testUnion2 = [string] extends [UnionType] ? true : false // true
type testUnion3 = [UnionType] extends [string] ? true : false // false
1
2
3
4
5
6
7
8
9
10
11
12

上面的 2 个例子中

ID 是 string/number 类型,所以 ID 取值的范围是比 string 类型的范围的大的,TestID 返回值自然为 false 了

而一个 string 类型,是 在 string|number 范围内的,所以自然为 true 了

其他在数组中的也是同理

理解上面举的例子的原理,就可以解决了 1097・IsUnion 这道题

# 用 extends 的特性解决 01097-medium-isunion

extends 是一个自带范围判断的判断符,只要在另外一个类型范围内,那就判定为 true

01097-medium-isunion 题目的需求:判断一个传入的类型是否 union 类型

什么是 union(联合)类型呢,就是由多个类型 联合而成,其中 | 就是为了 粘合多个类型的

测试用例如下:非常有代表性

type cases = [
  Expect<Equal<IsUnion<string>, false >>,
  Expect<Equal<IsUnion<string|number>, true >>,
  Expect<Equal<IsUnion<'a'|'b'|'c'|'d'>, true >>,
  Expect<Equal<IsUnion<undefined|null|void|''>, true >>,
  Expect<Equal<IsUnion<{ a: string }|{ a: number }>, true >>,
  Expect<Equal<IsUnion<{ a: string|number }>, false >>,
  Expect<Equal<IsUnion<[string|number]>, false >>,
  // Cases where T resolves to a non-union type.
  Expect<Equal<IsUnion<string|never>, false >>,
  Expect<Equal<IsUnion<string|unknown>, false >>,
  Expect<Equal<IsUnion<string|any>, false >>,
  Expect<Equal<IsUnion<string|'a'>, false >>,
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

根据上面 extends 的介绍,我们就可以利用下面的原理解决这个问题

  • string extends string | number 为 true
  • string | number extends string 为 false
  • TS 体操中的 Union(联合类型)会自动 “解构”

答案如下:

type IsUnion<T,U = T> = T extends U ? U extends T ? false : true : false
1

IsUnion 里面,T 会自动的 “解构”,就比如 传入的是 string|number

  1. T 会自动变成 string,然后和 U 进行比较
  2. T 在变成 number ,在和 U 比较
  3. U 同理 变成 string 和 T(string/number) 进行比较

所以看上去 T extends U 只是一个三目运算符,实际上他们已经运行 4 次比较了,只要有一次为 false,那 extends 的结果就为 false


不信?插个题外话证明下

我们把类型换成 'a' | 'b', 这个也是个 union 类型,然后我们把 extends 换成他们 2 个拼接

type TestUnion<T extends string, U extends string = T> = `${T}-${U}`
type ResultUnio = TestUnion<'a' | 'b'>

// type ResultUnio = "a-a" | "a-b" | "b-a" | "b-b"
1
2
3
4

看到结果后,应该就能理解上面说的,union 类型会自动的 “解构” 的意思了把,T 会自动解构为 a 和 b,U 也会自动解构为 a 和 b,然后两两配对组合;最终得出结果(不明白的在琢磨琢磨)

能理解这个 demo 后,后面还有几道题会用到这个知识点,圈起来要考

题外话结束


说回测试用例的几个例子

  • { a: string|number }[string|number] 不是联合类型,因为他们其实都属于同一个对象下的,他们的属性值是联合类型,而他们自身并不是

  • 剩下的最后 4 个测试用例中,比如 string|never , string|'a' 也是不符合 多个类型联合 的意义

    • never 说明没有一个类型符合,就好像绝育的小猫咪和正常的小猫咪还能生出新的小猫咪吗?不行的嘛。所以他们不能联合
    • 至于 string|'a' 'a' 本来就是属于 string 类型的,这就好像 一只公的小猫和一只公的黑色小猫,他们能 联合 吗?他们都属于 公猫,只有并集,联合不出来结果
    • 所以 string|any 同上面同理,any 包罗一切,没有东西可以和 any 组成联合

但是!他们虽然不能组成联合类型,但是这不会报错,像这种 脱裤子放屁的操作
TS 会自动帮他们取范围比较大的一个类型作为联合结果

就好比 一只公的小猫和一只公的黑色小猫,他们统称为 公猫

type StringUnion = string | 'a' // 结果为 string
type StringUnion2 = string | any // 结果为 any
1
2

# 使用小技巧实现 Equal 全等判断

要说 Equal 的使用场景,TS 类型体操的练习题每一题都用到了

我是在 2757・PartialByKeys 这一题才特别注意到的

type User = {
  name?: string
  age: number
  address: string
}

type User2 = {
  name?: string
} & {
  age: number
  address: string
}

type R = Equal<User1, User2> // false
type R2 = User1 extends User2 ? (User2 extends User1 ? true : false) : false // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

看上面的 demo,如果仅用 extends 来判断,他们是相等的(U1 == U2 && U2 == U1)
可是写法上确实有差别,User2 是通过交叉类型交叉在一起的
这也就是为什么 extends 做不了完全相等的原因

所以就出现了 Equal 的方案,看看 Equal 的实现

export type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false
1
2
3

一开始我也琢磨了很久

  • T 是那里冒出来的?
  • T 是作为泛型,传入一个函数内,然后在返回出来?
  • T 为什么会等于 X ?
  • T 又为什么等于 Y 了 ?

最后在这里看到了答案,略有感悟 How does the Equals work in typescript? (opens new window)

根据有括号先看括号的原则 把 Equal 的等式分成 3 段内容来看

  • (<T>() => T extends X ? 1 : 2) 假设为 1 式
  • (<T>() => T extends Y ? 1 : 2) 假设为 2 式

连起来看就是 : 1式 extends 2式 ? true : false

<T>() 怎么理解呢?有一句很重要的话 (也是摘自 stackoverflow 上的答案)

The assignability rule for conditional types <...> requires that the types after extends be "identical" as that is defined by the checkerF

对应翻译: 条件类型 <...> 的可分配性规则要求扩展后的类型与检查器定义的类型“相同”

根据上面说的 1式2式 来个 demo 理解下:

declare let x: <T>() => T extends number ? 1 : 2
declare let y: <T>() => T extends number ? 1 : 2
declare let z: <T>() => T extends string ? 1 : 2

var str: string = '1'
str = 2 // 报错

x = 100 // 报错
x = y
x = z // 报错
y = z // 报错
1
2
3
4
5
6
7
8
9
10
11
  • 2 赋值给 str 时会报错 Type 'number' is not assignable to type 'string'.
  • x 赋值为 100 时会报错 Type 'number' is not assignable to type '<T>() => T extends number ? 1 : 2'.

仔细观察这 2 个错误消息,他们都属于 ts(2322) 号错误。那么就可以理解为 <T>() => T extends number ? 1 : 2 实际上和 string 一样,是一个单一类型,而不是我们认为的函数

  • y 赋值给 x 啥事都没有

  • z 赋值给 x 和 z 赋值给 y 都会提示下面的错误

Type '<T>() => T extends string ? 1 : 2' is not assignable to type '\<T\>() => T extends number ? 1 : 2'.
  Type 'T extends string ? 1 : 2' is not assignable to type 'T extends number ? 1 : 2'.
    Type '1 | 2' is not assignable to type 'T extends number ? 1 : 2'.
      Type '1' is not assignable to type 'T extends number ? 1 : 2'.
1
2
3
4

说白了就是不同类型的不能赋值,因为 可分配性规则要求扩展后的类型与检查器定义的类型“相同”

看到这里,是不是大概就理解了?1 式 和 2 式其实最后只是一个写法比较夸张的 类型。而后面接的小尾巴(1 和 2,也真的是为了凑字数,为了 extends 凑齐字数用的)

比如稍微改一下,等式就全都报错了

而改一下泛型的定义,倒不会报错

总结起来还是那句话

条件类型 <...> 的可分配性规则要求扩展后的类型与检查器定义的类型“相同”

看懂了 demo 和解析的文字,那么看懂 Equal 也不在话下了,只要 1 式和 2 式 extends,在基于上面的“相同”特性,那就是全等了

# 和 Equal 很像的 Alike

Alike 和 Equal 一样是为了判断相等的,在之前一篇文章 《TS 类型体操 之 循环中的键值判断,as 关键字使用》 中有讲过 8・Readonly 2的测试用例就是用的 Alike

type User = {
  name?: string
  age: number
  address: string
}

type User2 = {
  name?: string
} & {
  age: number
  address: string
}

// 把 Equal 换成 Alike 得到的就是 true 的结果
type R = Alike<User1, User2> // true
type R2 = User1 extends User2 ? (User2 extends User1 ? true : false) : false // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果用 Alike,那么刚才 Equal 为 false 的 2 个对象又能重新变为 true 了

Alike 不能就单纯的理解是就是 T extends U && U extends 的实现,绝对不能

  • Alike 实现如下:
export type MergeInsertions<T> = T extends object ? { [K in keyof T]: MergeInsertions<T[K]> } : T

export type Alike<X, Y> = Equal<MergeInsertions<X>, MergeInsertions<Y>>
1
2
3

使用了 MergeInsertions 的工具类,这个的作用和昨天讲的 type Clone<T> = Pick<T, keyof T> 差不多!只是说我的 Pick 只能 Pick 一层,而 MergeInsertions 则是考虑到了对象多层嵌套的情况(升级版 Clone,有点浅拷贝和深拷贝的意思)

{} & {} 经过 MergeInsertions 处理后,也会被合并为一个对象 {},然后在拿去 Equal 对比。不得不说,真是妙

上面提到 Alike 不能就单纯的理解是就是 T extends U && U extends 的实现

看个案例:

type TestUnion = { a: string } | { a: number }

type IsLike = Alike<TestUnion, TestUnion> // true
type IsUnionResult = IsUnion<TestUnion> // true

type IsLike2 = Alike<string, string> // true
type IsUnionResult2 = IsUnion<string, string> // false
1
2
3
4
5
6
7

对于单一类型,T extends U && U extends 可以判断出是否 Union ,而 Alike 只能判断他们是否相等

所以要想判断 IsUnion 还得靠双 extends,Alike 只是一个宽松的相等运算符

# 总结

  • extends 是一个 子集的比较,只要 x 是 y 的子集,那么 x extends y 就为 true
  • 判断一个类型是否 union 类型,用到的也是 extends 子集 的特点
    • 如果 x 是 y 的子集,那么 y 就不可能是 x 的子集
    • x extend y 并且 y 又 extends x 的话,那只能说他们都是 单一的类型,不存在联合
  • Equal 函数很巧妙的用了 TS 的一个特性
    • The assignability rule for conditional types <...> requires that the types after extends be "identical" as that is defined by the checkerF
    • 通过一个类似公式代入的场景判断 2 个类型是否全等
    • 单一类型 和 交叉类型 不全等
  • 如果想把条件放宽松点,只想判断 2 个类型所有的字段都相同就是相等的话
    • 就要用上 Alike ,Alike 则是用了一个 MergeInsertions(Clone 升级版) 来把交叉类型合并为 单一类型
    • 有了单一类型,就可以用 Equal 来对比了
    • 对于单一类型,T extends U && U extends 可以判断出是否 Union ,而 Alike 只能判断他们是否相等(所以不要搞混 Alike 和 IsUnion 的实现原理)

无论是 IsUnion 的实现原理,还是 Alike 的宽松语法对比,Equal 的严格全等,extends 的范围子集判断;都有各自的使用场景,日常做题/开发都要 看题下方案

Last Updated: 1/7/2024, 5:51:59 PM