TypeScript 大挑战(三)

255 阅读6分钟

这是 TypeScript 大挑战系列的第 3 篇,笔者计划用 6 个月时间完成 type-challenges 项目中的 133 个挑战并记录下自己的收获,目前还剩余 127 个,截止日期为 2023 年 1 月25 日。

挑战进度

7. Awaited

挑战内容

假如我们有一个 Promise 对象,这个 Promise 对象会返回一个类型。在 TS 中,我们用 Promise 中的 T 来描述这个 Promise 返回的类型。请你实现一个类型工具,可以获取这个类型。

例如:Promise<Type>,请你返回 Type 类型。

// TODO: 实现 MyAwaited
type MyAwaited = any;

// Result1 为 string
type Type1 = Promise<string>
type Result1 = MyAwaited<Type1>

// 支持 Promise 嵌套,Result2 为 string
type Type2 = Promise<Promise<string>>
type Result2 = MyAwaited<Type2>

// 传入非 Promise 对象,抛出错误
type Result3 = MyAwaited<string>

知识点:infer / 递归调用

题目解析

题目中有两个地方需要特别关注:

  1. 对于 Promise 嵌套的情况,要能够取出最里层的数据类型,例如对于 Promise<Promise<string>> 类型,应该拿到 string
  2. 当传入非 Promise 类型的时候,应该报错方便提前发现问题。

首先不考虑这两个特殊点,从最简单的情况入手。

为了取出 Promise 包裹的类型,需要使用在大挑战(二)中介绍到的 infer,可以实现如下最简单的版本:

type MyAwaited<T>
  = T extends Promise<infer K> ? K : never

接下来考虑如何处理嵌套的情况。平时我们在写普通 JavaScript 代码的时候,主要是通过递归调用和循环两种方式来处理嵌套的情况,然而在 TypeScript 中声明 Type 类型的时候,没有办法使用循环,所以主要是通过递归调用处理嵌套,对于 infer K 推断出来的类型 K,判断是否为 Promise,若是的话则将它带入 MyAwaited<K> 再计算一遍: :

type MyAwaited<T>
  = T extends Promise<infer K>
    ? (K extends Promise<any> ? MyAwaited<K> : K)
    : never

最后来处理当传入的类型不是 Promise 时应该抛出错误的问题,一开始会觉得没有头绪,因为没有主动抛出错误的方法,但是转念一想其实很简单:声明 T 是 Promise 类型后,TypeScript 编译器会自动检查类型并报错:

type MyAwaited<T extends Promise<any>>
  = ...

题目答案

type MyAwaited<T extends Promise<any>>
  = T extends Promise<infer K>
    ? (K extends Promise<any> ? MyAwaited<K> : K)
    : never

额外思考:将答案中的两个 any 替换成 unknown 也是正确的,大家知道他们俩个的区别吗?

8. If

挑战内容

实现一个 If 类型,它接收一个条件类型 C ,一个判断为真时的返回类型 T ,以及一个判断为假时的返回类型 FC 只能是 true 或者 falseTF 可以是任意类型。

// TODO: 实现 If
type If<C, T, F> = any

// A 为 'a'
type A = If<true, 'a', 'b'>

// B 为 'b'
type B = If<false, 'a', 'b'>

// 传入的不是 true / false,抛出错误
type error = If<null, 'a', 'b'>

题目解析

这个题目比较简单,关键点在于如何判断 Ctrue / false

其实对于 true / false,甚至 1 / 2 / null / undefined / 'hello' 这些基本类型的变量,可以直接使用 extends 判断:

// Bool1 为 1
type Bool1 = true extends true ? 1 : 0

// Bool2 为 1
type Bool2 = false extends false ? 1 : 0

// Number1 为 1
type Number1 = 1 extends 1 ? 1 : 0

// String1 为 1
type String1 = 'hello' extends 'hello' ? 1 : 0

题目答案

和上一题类似,也加上 C extends boolean 的限制,这样当 C 不是 true/false 的时候编译器会抛出错误:

type If<C extends boolean, T, F>
  = C extends true ? T : F;

9. Concat

挑战内容

在类型系统里实现 JavaScript 内置的 Array.concat 方法,这个类型接受两个参数,返回的新数组类型应该按照输入参数从左到右的顺序合并为一个新的数组。

// TODO: 实现 Concat
type Concat<T, U> = any

// Result1 为 [1, 2]
type Result1 = Concat<[1], [2]>

题目解析

这个题目同样比较简单,只要我们了解到在类型系统中,也可以通过 ...array 的方式来取出数组类型的全部元素即可:

// 定义元组类型
type Array1 = [1, 2]

// A 为 [1, 2],元组类型可以直接遍历
type A = [...Array1]

// 定义常量数组
const array2 = [1, 2] as const

// B 为 [1, 2],常量数组的类型可以直接遍历
type B = [...typeof array2]

题目答案

type Concat<
  T extends any[],
  U extends any[]
> = [...T, ...U]

10. Includes

挑战内容

在类型系统里实现 JavaScript 的 Array.includes 方法,这个类型接受两个参数,返回的类型要么是 true 要么是 false

// TODO: 实现 Includes
type Includes<
  T extends readonly any[],
  U,
> = any

// is1 为 true
type is1 = Includes<[1, 2], 1>

// is2 为 false
type is2 = Includes<[1, 2], 3>

// is3 为 false
type is3 = Includes<[1, 2], 1 | 2>

题目解析

这个题目也涉及到了循环判断的场景:需要取出数组 T 中的每一个元素,判断是否等于 U。对于这样的场景,可以套用 7. Awaited 使用的递归调用方式:

type Includes<
  T extends readonly any[],
  U,
> = T extends [infer First, ...infer Rest]
  ? ((First 等于 U) ? true : Includes<Rest, U>)
  : false

递归调用的框架搭起来后就逻辑就很清晰了:

  1. 先取出数组 T 的第一个元素 First
  2. 如果 FirstU 相等,则返回 true
  3. 如果 FristU 不相等,则取出剩下元素组成的数组 Rest
  4. 如果 Rest 不为空,则带入第 1 步再次判断;
  5. 如果 Rest 为空,则返回 false

现在剩下的问题就是如何判断 First 是否等于 U

先来实现一个基础版本 EqualV1

8. If 中说过对于基本数据类型,可以通过 extends 判断两个类型是否相等,例如对于类型 T 和 K,简单情况下若 T extends KK extends T,可以说 T 等于 K:

type EqualV1<T, K>
  = T extends K
    ? (K extends T ? true : false)
    : false

// A 为 true
type A = EqualV1<true, true>

// B 为 false
type B = EqualV1<1, 2>

// C 为 false
type C = EqualV1<'hello', 1>

// D 为 true,
// 但是 {a: 1} 和 {readonly a: 1} 不等
type D = EqualV1<
  { a: 1 },
  { readonly a: 1 }
>

之所以 D 是 true,是因为 TypeScript 编译器在判断两个类型是否满足 T extends K 时,我们可以形象理解为类型 K 的变量是否可以赋值给类型 T 的变量,而对于对象变量而言, readonly 等属性修饰的情况下都是可以互相赋值的:

type A = { a: number }
type ReadonlyA = { readonly a: number }

const a1: A = { a: 1 }
const a2: ReadonlyA = { a: 2 }

// ReadonlyA 类型变量可以赋值给 A 类型
const a3: A = a2;

// A 类型变量也可以赋值给 ReadonlyA 类型
const a4: ReadonlyA = a1;

为了解决 EqualV1 对于 readonly 属性修饰的对象判断不准的问题,我们需要一点奇技淫巧:

type EqualV2<A,B>
    =(<T>() => T extends A ? 1 : 0) extends
      (<T>() => T extends B ? 1 : 0)
      ? true : false;

这是来自 Github - type level equal operator 的方法,牛啊👍。

原理是当条件类型中的 T 未知时,会延迟到调用时进行判断,判断时会调用 TypeScript 内部的 isTypeIdenticalTo 方法,当且仅当两个条件类型满足如下条件时返回 true

  • 两个条件类型的约束(constraint)相同
  • 两个条件类型的 true / false 分支值相同

题目答案

type Equal<A,B>
    =(<T>() => T extends A ? 1 : 0) extends
      (<T>() => T extends B ? 1 : 0)
      ? true : false
    
type Includes<
  T extends readonly any[],
  U,
> = T extends [infer First, ...infer Rest]
  ? (Equal<First, U> extends true ? true : Includes<Rest, U>)
  : false

结语

这一次的挑战到此就结束啦,最大的收获就是通过 7. Awaited10. Includes 这两道题目,可以总结出如下递归调用解决嵌套问题和数组遍历问题的模板:

type Solution<
  T extends readonly any[],
> = T extends [infer First, ...infer Rest]
  ? (First 满足要求 ? true : Solution<Rest, U>)
  : false