TS类型体操(一) 基础知识

1,653 阅读13分钟

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看作是neverunknown的结合体,用一句描述就是:any是所有类型的子集,同时也是所有类型的父集

    如果你对这种描述有疑问,可以看完后面的内容再回头重新梳理这句话。

  • 联合类型|相当于求并集

  • 交叉类型&相当于求交集

  • TS工具类Exclude<T, K>相当于求补集

  • 其他TS提供的基础类型,比如numberstringnullobjectboolean等等,它们之间没有父子集关系,也不存在交集,它们的交叉类型是空集,也就是never

    type 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 | nullHTMLElement的父集,赋值不成立。

函数的父子集问题

关于函数的父子集问题,直接上结论:

  • 所有函数都是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中的三种作用:

  1. 继承
interface A {
  a: string
  b: number
}

interface B extends A {
  c: boolean
}
  1. 类型约束,一般用于泛型。
type GetSelf<T extends string> = T

type s = GetSelf<'abc' | '123'> // √
type n = GetSelf<123> // × 类型“number”不满足约束“string”。ts(2344)
  1. 判断类型与类型的继承关系
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-challengesEqual 的写法,它能够解决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内置的工具类开始,我们要试着自己去实现这些工具类。