TS类型体操(四) 操场搭建以及热身运动

737 阅读5分钟

TS类型体操(四) 搭建操场 + 热身操

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

TS类型体操(二) TS内置工具类1

TS类型体操(三) TS内置工具类2

今天我们来搭建操场,开始做题。

搭建操场

克隆 type-challenges 项目到本地,如果不打算fork,可以添加depth参数减少体积:

git clone git@github.com:type-challenges/type-challenges.git --depth 1

安装依赖:

pnpm install

然后就可以用type-challenges 提供的脚本生成所有题目:

我个人不建议这样一次生成所有题目,参考我后文的做法。

pnpm generate

构建前会让我们选择语言

tc选择语言.jpg

选择zh-CN中文,虽然有些题目并没有翻译,但依旧会生成英文的版本。

构建完以后,根目录下会新增一个playground文件夹,所有的题目都在这里。

题目列表.jpg

写一个按需创建题目的脚本

但是,所有题目都放操场里面不太方便,哪些做过哪些没做过,不好管理,于是我写了一个按序号生成题目的脚本——

scripts目录下创建文件:generate-single.ts

import path from 'path'
import { argv } from 'process'
import fs from 'fs-extra'
import prompts from 'prompts'
import c from 'picocolors'
import { loadQuizByNo, resolveInfo } from './loader'
import { formatToCode } from './actions/utils/formatToCode'
import { getQuestionFullName } from './actions/issue-pr'
import type { QuizMetaInfo } from './types'

// 替换部分中文题目的难度标识为英文
const REPLACE: Record<string, string> = {
  简单: 'easy',
  中等: 'medium',
  困难: 'hard',
}
const LANGRUAGE = 'zh-CN'

async function generateSingle() {
  console.log(' ')
  let num = argv[argv.length - 1]
  const regex = /^\d+$/
  if (!regex.exec(num)) {
    const result = await prompts([{
      type: 'number',
      name: 'num',
      message: '请输入题目序号:',
    }])
    if (!result?.num)
      return console.log(c.yellow('已取消'))
    num = result.num
  }

  num = String(num).padStart(5, '0')
  const quiz = await loadQuizByNo(num)
  if (!quiz) {
    console.log(' ')
    return console.log(c.yellow(`不存在的题目序号:${num}`))
  }
  let { difficulty, title } = resolveInfo(quiz, LANGRUAGE) as QuizMetaInfo & { difficulty: string }
  if (difficulty in REPLACE)
    difficulty = REPLACE[difficulty]
  const quizePath = path.join(__dirname, '../answers', difficulty)
  const filepath = path.join(quizePath, `${getQuestionFullName(quiz.no, difficulty, title)}.ts`)
  if (fs.existsSync(filepath))
    return console.log(`${c.bold(c.red('文件已存在:'))} ${c.dim(filepath)}`)
  const code = formatToCode(quiz, LANGRUAGE)
  await fs.ensureDir(quizePath)
  await fs.writeFile(filepath, code, 'utf-8')
  console.log(' ')
  console.log(`${c.bold(c.green('已生成:'))} ${c.dim(filepath)}`)
}

generateSingle()

然后将脚本添加到package.json

{
  "scripts": {
    "readme": "esno ./scripts/readme.ts",
    "build": "esno ./scripts/build.ts",
    "generate": "esno ./scripts/generate-play.ts",
+    "single": "esno ./scripts/generate-single.ts",
    "lint": "eslint .",
    "translate": "esno ./scripts/translate-cli.ts",
    "utils:release": "pnpm -C utils release"
  }
}

这样,我们就可以执行命令pnpm single,终端会提示我们输入需要的题目序号。或者直接在后面加上题目的序号,例如:

pnpm single 533

然后就会将对应序号的题目生成在answers目录下,并且我还按难度进行了归集。

按需生成题目.jpg

你可以直接克隆我fork的仓库:taiyuuki/type-challenges

热身操——几道简单题目

我们先做一下easy难度的题目热热身,这些题目其实大都没什么好讲的,因为都比较简单,其中有些是我前几篇文章就提到过的,例如获取元组第一个元素、获取数组长度、PickExclude等等,这些我就跳过了。

为了节省文章字数,我只贴出题目的描述,测试用例之类的代码,除非有必要,否则我就不列出来的。

我的答案只能算是抛砖引玉,仅供参考,也许有更好的做法,欢迎大家批评指正!!

11 - TupleToObject

题目:传入一个元组类型,将这个元组类型转换为对象类型,这个对象类型的键/值都是从元组中遍历出来。

本题涉及知识点

  • 映射类型
  • 元组转联合类型
type Key = string | number | symbol // 对象的键类型约束
type TupleToObject<T extends readonly Key[]> = { // 根据测试用例的要求,这里需要readonly进行泛型约束
  [K in T[number]]: K // T[number] 可以将元组类型转为联合类型
} 

189 - Awaited

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

本题涉及知识点:

  • infer
  • 递归
  • PromiseLike

测试用例;

type X = Promise<string>
type Y = Promise<{ field: number }>
type Z = Promise<Promise<string | number>>
type Z1 = Promise<Promise<Promise<string | boolean>>>
interface T { then: (onfulfilled: (arg: number) => any) => any }

type cases = [
  Expect<Equal<MyAwaited<X>, string>>,
  Expect<Equal<MyAwaited<Y>, { field: number }>>,
  Expect<Equal<MyAwaited<Z>, string | number>>,
  Expect<Equal<MyAwaited<Z1>, string | boolean>>,
  Expect<Equal<MyAwaited<T>, number>>,
]

// @ts-expect-error
type error = MyAwaited<number>

看到这题,我最先冒出的想法是这样的:

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

但我们发现,测试用例要求支持多层Promise,所以还需要进行递归:

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

但是,测试用例还要求支持Thenable对象

注:所谓Thenable对象,就是指实现了then函数的对象。

那就用PromiseLike替换Promise,PromiseLike就是一个Thenable的对象类型。

type MyAwaited<T> = T extends PromiseLike<infer K> ? MyAwaited<K> : T

还有但是,测试用例最后一行要求T不能是number,也就是说必须有泛型约束。

如果添加了泛型约束,相应的,递归前就得额外做一次类型判断,否则K会不符合这个泛型约束:

type MyAwaited<T extends PromiseLike<any>>
= T extends PromiseLike<infer K>
  ? K extends PromiseLike<any>
    ? MyAwaited<K>
    : K
  : T

其实,如果测试用例没有最后一行,上面倒数第二个才是最佳答案,简洁又易懂。

268 - If

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

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

这道题简单得让我怀疑:上一道题是不是标错难度了。

533 - Concat

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

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

本题涉及知识点:

  • 数组解构

这道题也很简单,解构合并两个数组。

898 - Includes

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

这道题需要用到Equal,因为按照题目的要求,只有类型完全相等才符合条件。虽然也有不直接使用Equal的做法,但实现方式更难理解。

type-challenge 提供的Equal我们可以直接拿来用,但为了答案的完整性,最好还是自己写一遍:

type IsEqual<G, U> =
(<T>() => T extends G ? 1 : 2) extends
(<T>() => T extends U ? 1 : 2)
  ? true
  : false

type Includes<T extends readonly any[], U> =
 T extends [infer Head, ...infer Tail] // Head是数组第一个元素,Tail是数组剩余元素构成的数组
   ? IsEqual<Head, U> extends true // 对比数组第一个元素和U
     ? true
     : Includes<Tail, U> // 递归
   : false // extends判断为假,说明数组已经遍历完

这道题向我们展示了利用递归遍历数组的常规操作。

3057 - Push

题目:在类型系统里实现通用的 Array.push

type Push<T extends unknown[], U> = [...T, U]

话说easy的题目,难度跨度挺大的,这又是一道送分题。

3060 - Unshift

题目:实现类型版本的 Array.unshift

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

……