TS类型体操(一) 基础知识
前言
最近在玩TS类型体操,写文分享一下我的游玩心得。
后续计划做一些 type-challenges 上具体的题目,分享类型体操的常规操作,分析解题思路等等,这篇是第一篇(也可能是最后一篇),全是基础知识…
虽说是基础知识,但不是面向零基础的,也构不成教程,更像是一篇笔记或心得体会,如果对文章的内容有疑问,或发现有错误,欢迎批评指正!
区分值和类型
区分值和类型是理解TS类型系统的关键要素之一,因为类型体操是建立在纯类型系统上的。
type T = {
name: 'Jack',
age: 18,
}
type K = keyof T // type K = 'name' | 'age'
记住:我们不能把值当做类型来操作,也不能把类型当做值来做计算。
这一点看似显而易见,但是在开发中,很多初学者会犯类似这样的错误:
const t = {
name: 'Jack',
age: 18,
}
type K = keyof t // 报错:“t”表示值,但在此处用作类型。是否指“类型 t”?ts(2749)
keyof可以获取一个对象类型的属性构成的联合类型,但是它只能用在类型上,不能对值进行操作。
当然,解决办法很简单,加一个typeof就行了,typeof可以将值转换为类型:
const t = {
name: 'Jack',
age: 18
}
type K = keyof typeof t // type K = 'name' | 'age'
也许你会觉得这个问题很低端,但很多半吊子TS玩家并没有仔细思考过这个问题,所以我必须在最开始强调一下。
什么是类型体操——写一个返回类型的“函数”
本节开始进入正题,首先第一个问题是:TS类型体操是什么?
我举个简单例子加以说明——
一个简单例子
有这么一个两数相加的函数:
let fn = (x, y) => x + y
还有这么一个求联合类型的类型:
type Fn<X, Y> = X | Y
对比一下你会发现,虽然二者功能不同,但下面这个Fn是不是和上面的fn函数很像?只不过Fn得到的是类型,而fn返回的是值。
泛型X和Y则可以看作是Fn这个“函数”的参数,我们可以用Fn得到任意两种类型的联合类型:
type StrOrNum = Fn<string, number> // type StrOrNum = string | number
type Player = Fn<'p1', 'p2'> // type Player = 'p1' | 'p2'
是的,类型体操的本质就是这么简单,我们要做的,就是写一个类似“函数”的类型,以类型作为参数(泛型),通过“函数”计算,返回需要的类型。
一个稍复杂的例子
上面JS的例子是做加法,而TS是做联合类型运算,可能有人还感受不到其相似之处。
事实上,TS纯类型也是可以做类似加减运算的,只不过实现起来不是+这么简单,正整数的加法是这样的:
// 数字转元组
type ToTuple<N, R extends unknown[] = []>
= R['length'] extends N
? R
: ToTuple<N, [unknown, ...R]>
// 正整数求和
type Add<A, B> = [...ToTuple<A>, ...ToTuple<B>]['length']
Add<A, B>可以计算两个数字之和:
type a = Add<1, 2> // type a = 3
type b = Add<5, 3> // type b = 8
type c = Add<8, 6> // type c = 14
// 避免有萌新不理解,这里再不厌其烦的强调一遍:这里的数字 3 8 14,它们都是类型而不是值!!
如果以上你看不懂,没关系,我把它用JS的逻辑写一遍,两相对照,你基本就懂了:
// toTuple函数返回一个长度为n的数组
const toTuple = (n, r = []) => {
if (r.length === n) {
return r
} else {
// 递归
return toTuple(n, [...r, 0])
}
}
// 两数相加:其实就是合并两个数组,然后返回总长度
const add = (a, b) => {
return [...toTuple(a), ...toTuple(b)].length
}
如果我们将toTuple改成三目运算的写法,就和上面TS更像了:
const toTuple = (n, r = []) => r.length === n ? r : toTuple(n, [...r, 0])
const add = (a, b) => [...toTuple(a), ...toTuple(b)].length
利用集合思想理解TS类型系统
以上内容算是热身,这一节开始,我要讨论一些与体操本身无关,但很重要的东西:利用集合的思想理解TS的类型系统。
我们高中都应该学过集合,所以这里不打算展开讲。
网上有不少探讨集合与TS之间关系的文章,感兴趣的可以自行搜索。
这里我只列出一些结论性的东西,尤其需要注意类型的父子集关系,如果有必要,我会对其中一些特定内容展开说明。
类型与集合的对应关系
-
unknown相当于全集,其他所有类型都是unknown的子集。 -
never相当于空集,never是其他所有类型的子集。 -
any是个特例,在集合中没有与之对应的概念,网上一些文章将其描述为“TS的逃生舱”,大概是指初学者遇到类型错误又没办法解决时,它可以用来逃生。我们可以把
any看作是never和unknown的结合体,用一句描述就是:any是所有类型的子集,同时也是所有类型的父集。如果你对这种描述有疑问,可以看完后面的内容再回头重新梳理这句话。
-
联合类型
|相当于求并集 -
交叉类型
&相当于求交集 -
TS工具类
Exclude<T, K>相当于求补集 -
其他TS提供的基础类型,比如
number,string,null,object,boolean等等,它们之间没有父子集关系,也不存在交集,它们的交叉类型是空集,也就是nevertype a = string & number // type a = never
并集与交集
关于并集与交集,有两个重要的结论:
-
两个集合的并集,其结果必定是这两个集合共同的父集,例如:
type a = 123 type b = 'abc' type c = a | b // type c = 123 | 'abc'这里的c是a的父集,也是b的父集
-
两个集合的交集,其结果必定是这两个集合共同的子集,例如:
type a = 1 | 2 type b = 2 | 3 type c = a & b // type c = 2这里的c是a的子集,也是b的子集。
对象类型的交叉类型
对两个对象类型求交集:
interface A {
a: string
n: boolean
}
interface B {
b: number
n: boolean
}
type C = A & B
这里的C结果是:
type C = {
a: string
b: number
n: boolean
}
很多人会觉得这个结果反直觉,但从集合的角度来看,这个结果是符合交集预期的,因为只有这样,C才既是A的子集,也是B的子集。
也就是说,这里的C,其实和下面的D是一样的:
interface A {
a: string
n: boolean
}
interface B {
b: number
n: boolean
}
interface D extends A, B {}
对象类型的联合类型
interface A {
a: string
n: boolean
}
interface B {
b: number
n: boolean
}
type C = A | B
这里我就不展开分析了,你只需要思考一个问题:如何才能让C既是A的父集,也是B的父集?
只要想清楚了这个问题,你就会发现,它的结果不仅不反直觉,而且是理所应当的。
父子集的关系
为什么我要长篇大论的总结TS与集合的关系,并反复强调父子集关系呢?请容我慢慢道来——
赋值与断言
记住两个结论:
- 父集类型的值可以断言为子集类型,反过来不行。
const a: 'str' = 'str' as string // × 子集不能断言为父集
const b = a as undefined // √ 父集断言为子集 string | undefined -> undefined
const c = a! // √ 非空断言,父集断言为子集 string | undefined -> string
const d = a as number // × 类型错误:类型“string”不可与类型“number”进行比较。ts(2352)
- 子集类型的值可以赋值给父集类型的值,反过来不行。
例如:
// 获取HTML元素的Text文本
function getText(el: HTMLElement) {
return el.textContent
}
const el = document.getElementById('el')
const text = getText(el)// 类型错误:不能将类型“null”分配给类型“HTMLElement”。ts(2345)
报错原因在于:getElementById返回值类型是HTMLElement | null,而getText的参数类型是HTMLElement,前者是后者的父集,父集类型的值不能赋值给子集类型的值。
如果我们确定id为el的元素一定存在,这时我们可以使用断言:
const el = document.getElementById('el') as HTMLElement
const text = getText(el)
因为HTMLElement | null联合类型是HTMLElement的父集,父集类型的值可以断言为子集,断言成立。
但是,我们不能这么做:
// 类型错误:不能将类型“null”分配给类型“HTMLElement”。ts(2322)
const el: HTMLElement = document.getElementById('el')
原因是一样的,父集类型的值不能赋值给子集类型的值,HTMLElement | null是HTMLElement的父集,赋值不成立。
函数的父子集问题
关于函数的父子集问题,直接上结论:
-
所有函数都是
Function的子集 -
参数问题
-
参数是父集,则函数是子集——这一条是非常反直觉的
type t1 = ((a: number) => any) extends ((a: 0) => any) ? true : false // true type t2 = ((a: 0) => any) extends ((a: number) => any) ? true : false // false -
无参是有参的子集
type t3 = (() => any) extends ((a: string) => any) ? true : false // true -
少参是多参的子集
type t4 = ((a: string) => any) extends ((a: string, b: number) => any) ? true : false // true // 但是少的参数必须从后面开始,这里少的是第一个参数,不是子集 type t5 = ((b: number) => any) extends ((a: string, b: number) => any) ? true : false // false
-
-
返回值问题
-
返回值是子集,则函数也是子集——这很符合直觉
type t6 = (() => 0) extends (() => number) ? true : false // true type t7 = (() => number) extends (() => 0) ? true : false // false -
有返回值是无返回值(void)的子集
type t8 = (() => void) extends (() => number) ? true : false // false type t9 = (() => number) extends (() => void) ? true : false // true
-
extends——判断类型的父子集关系
TS的纯类型系统是没有类似if这样的判断语句的,也不支持==之类的运算符。那么,我们该如何进行类型运算呢,例如:如何判断两个类型相等?
type a = 0
type b = 0
type t = a == b// 语法错误
从集合的角度来看,如果两个集合相等,意味着:A ⊆ B 且 B ⊆ A。
也就是说,只要两个类型互相都是对方的子集,就可以认定两个类型是相等的(any除外)。
TS提供的extends关键字,可以判断一个类型是否是另一个类型的子集。例如:
type a = 0 | 1
type b = number
type c = a extends b ? true : false // type c = true - 说明联合类型 0 | 1 是number的子集
可以发现它的用法很像三目运算符,但要注意的是,extends本身是没有返回值的,也就是说,这样的语句是不成立的:
type c = a extends b // 语法错误
用extends验证一些类型的父子集关系:
type a1 = never extends never ? true : false // true
type a2 = never extends string ? true : false // true
type a3 = number extends never ? true : false // false
type a4 = object extends unknown ? true : false // true
type a5 = [] extends unknown ? true : false // true
type a6 = any extends unknown ? true : false // true
type a7 = unknown extends any ? true : false // true
type a8 = unknown extends unknown ? true : false // true
type a9 = unknown extends never ? true : false // false
extends的三种作用
顺便总结一下extends在TS中的三种作用:
- 继承
interface A {
a: string
b: number
}
interface B extends A {
c: boolean
}
- 类型约束,一般用于泛型。
type GetSelf<T extends string> = T
type s = GetSelf<'abc' | '123'> // √
type n = GetSelf<123> // × 类型“number”不满足约束“string”。ts(2344)
- 判断类型与类型的继承关系
type E<A, B> = A extends B ? A : B
三者的作用是各自独立的,不要混同的去理解。
判断类型相等
回到上面提到的问题,如何判断两个类型相等呢?
从最基础的开始
按照A ⊆ B 且 B ⊆ A的思路,我们可以这样:
type BaseEqual<A, B> = A extends B ? (B extends A ? true : false) : false
分别判断A和B是否是对方的子集,看上去不错,但是这么做存在两个问题:
-
泛型的联合类型分发机制会导致结果不符合预期,这个问题下一节细讲,这里举一个例子:
type t1 = BaseEqual<123 | 234, 123 | 234> // boolean - 预期结果应该是true -
any是个特例,any使用extends的各种结果如下:type t1 = any extends unknown ? true : false // true type t2 = any extends string ? true : false // boolean - string换成unknown以外的其他类型,其结果都是boolean type t3 = string extends any ? true : false // true - string换成其他任意类型,其结果都是true type t4 = any extends any ? true : false // true type e = BaseEqual<any, 123> // boolean - 预期结果应该是false
泛型的联合类型分发
联合类型分发(distributive conditional types),是TS处理泛型的一种机制,为了便于理解,下面举例说明:
// 当不使用泛型时,联合类型不会分发
type t1 = (number | string) extends number ? true : false // false - 符合预期结果
// 但如果使用泛型,会发生类型分发
type E<T, K> = T extends K ? true : false
type t2 = E<number | string, number> // boolean - 预期结果是false
其原因就在于,在计算T extends K时,如果T是联合类型,会对T的类型进行分发(所谓分发,你可以理解为遍历),然后分别计算结果,最后将各自的结果重新组合为联合类型。
上面例子的计算过程是这样的:
type E<T, K> = T extends K ? true : false
type t2 = E<number | string, number>
// 相当于↓
type t2 = (number extends number ? true : false) | (string extends number ? true : false)
// 结果↓
type t2 = true | false
// 结果↓
type t2 = boolean
需要注意的是,分发仅针对位于extends之前的类型,也就是上例中的T,如果K是联合类型,K并不会分发。例如:
type E<T, K> = T extends K ? true : false
type t2 = E<number, number | string> // true - 符合预期结果
为了避免联合类型分发,我们可以用[]将类型括起来:
type AEqual<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
type t = E<number | string, number> // false - 符合预期结果
但是,any的问题依旧存在。
type-challenge的方案
在TypeScript Github的这个Issue中,提出了以下解决方案。
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2)
? true
: false
这也是 type-challenges 里 Equal 的写法,它能够解决any的问题:
type t1 = Equal<any, any> // true
type t2 = Equal<any, {}> // false
type t3 = Equal<never, any> // false
其原理一两句话很难解释清楚,感兴趣的可以参考StackOverflow上的这个How does the Equals work in typescript?
虽然这个方案能解决any的问题,但是它也并非完美,Github以及StackOverflow上都有人给出了很多反例,例如:
type AEqual<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2)
? true
: false
type e1 = Equal<{x: 1} & {y: 2}, {x: 1, y: 2}> // false - 预期应该是true
type e2 = AEqual<{x: 1} & {y: 2}, {x: 1, y: 2}> // true
type e3 = Equal<1 | number & {}, number> // false - 预期应该是true
type e4 = AEqual<1 | number & {}, number> // true
尤其以第1个反例最为要命,毕竟,对象类型的交叉是很常用的。
总之,虽然我花了不少篇幅介绍如何判断类型相等,但实际做类型体操时,我们一般不会直接用到它,而type-challenge项目里的测试用例,已经帮我们规避了上述问题,我的主要目的,是借这个话题,熟悉对extends的作用和使用方式。
基础知识篇就到此为止,下一节从最简单的——TS内置的工具类开始,我们要试着自己去实现这些工具类。