一起来刻意练习TypeScript类型体操吧(一)

1,861 阅读8分钟

1 背景

TypeScript作为补充JavaScript静态类型检查能力的语言已经成为了主流,除了在声明、参数、返回值等地方添加类型声明之外,它本身对于类型的操作也完全是自成一套体系,但是相比于正常编程语言提供的语法,它又不是那么的直接,所以在编程的思路上需要刻意练习。type-challenges 是一个集合了TypeScript类型编程的开源项目,目前里边有100多道题目,按照难易程度进行了划分,我们一起来挑战这些题目,然后分析里边用到的语法,达到练习的目的。

2 开始练习

2.1 从Pick开始准备基础知识

题目:

import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Expected1, MyPick<Todo, 'title'>>>,
  Expect<Equal<Expected2, MyPick<Todo, 'title' | 'completed'>>>,
  // @ts-expect-error
  MyPick<Todo, 'title' | 'completed' | 'invalid'>,
]
interface Todo {
  title: string
  description: string
  completed: boolean
}
interface Expected1 {
  title: string
}
interface Expected2 {
  title: string
  completed: boolean
}

题目验证:

每一题都会提供一些测试case供我们验证编程的正确性,@type-challenges/utils 提供了一些类型帮助我们验证编程的正确性,其中Expect的作用是验证结果是true;Equal是判断两个参数是否是相等的; // @ts-expect-error是TS中标志下一行会报错。

类型编程思想

我们可以把type 当作所有声明变量和函数的关键字,我们可以把MyPick就当成是一个函数,我们用type来定义。把泛型<>就当作是参数,参数就是类型,返回值也是类型。

题目分析

本题 提供了三个case,可以分析出MyPick就是从第一个参数类型中筛选属性值,而筛选的属性值是第二个参数值指定,同时如果第二个参数指定的值在第一个参数中没有则报错。

题目答案

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

题目解析

  • 1 遍历语法: 首先是用了类型映射in的语法, 可以遍历联合类型中的每一项,P就是每一项。
  • 2 取值语法: T[P]就是取的T类型中属性值为P的类型。 Todo["title"]就是string, Todo["completed"]就是boolean
  • 3 keyof语法: 可以取类型的所有key值作为联合类型。 keyof Todo 就是'title' | 'description' | 'completed'
  • 4 extends类型约束: 左边的类型 需要满足右边类型的约束。 如果 K extends 'title' | 'description' 那么K中的取值就必须能够赋值给'title' | 'description', 当不满足约束条件时则抛出错误。

2.2 Readonly

题目:

import type { Equal, Expect } from '@type-challenges/utils'


type cases = [
  Expect<Equal<MyReadonly<Todo1>, Readonly<Todo1>>>,
]
interface Todo1 {
  title: string
  description: string
  completed: boolean
  meta: {
    author: string
  }
}

题目解析:

TypeScript中提供了readonly的修饰符,MyReadonly的作用就是让每一个属性值都是readonly的,所以需要的就是遍历属性 设置readonly。

题目答案:

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

2.3 Tuple To Object

题目:

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
const tupleNumber = [1, 2, 3, 4] as const
const tupleMix = [1, '2', 3, '4'] as const


type cases = [
  Expect<Equal<TupleToObject<typeof tuple>, { tesla: 'tesla'; 'model 3': 'model 3'; 'model X': 'model X'; 'model Y': 'model Y' }>>,
  Expect<Equal<TupleToObject<typeof tupleNumber>, { 1: 1; 2: 2; 3: 3; 4: 4 }>>,
  Expect<Equal<TupleToObject<typeof tupleMix>, { 1: 1; '2': '2'; 3: 3; '4': '4' }>>,
]

// @ts-expect-error
type error = TupleToObject<[[1, 2], {}]>

题目解析:

可以理解成就是把数组类型变成key值和value值相等的对象类型。所以思路应该是遍历数组的每一个key值。数组类型是一个特殊的对象类型, 获取数组类型所有元素的联合类型,TypeScript提供了。

T[number]语法 , 例如

type a = ['first', 'second', 'third']
type b = a[number] // b 为 'first' | 'second' | 'third'

实现了数组类型到联合类型的转换。

所以我们可以想到, 约束一下是数组类型

type TupleToObject<T extends any[]> = {
  [K in T[number]]: K
}

然后会提示

类型 "readonly ["tesla", "model 3", "model X", "model Y"]""readonly",不能分配给可变类型 "any[]"

原因就是只读类型不能分配给可变类型,想想就是如果只读的赋值给了可变的,那么不就导致只读的也可变了吗? 所以也需要在约束中添加只读

type TupleToObject<T extends readonly any[]> = {
  [K in T[number]]: K
}

还差一个// @ts-expect-error 没有满足,是因为我们没有约束key值的类型 而是使用了any,所以传入了[[1, 2], {}]也没有报错, 再添加对数组中元素的类型进行约束。

type TupleToObject<T extends readonly (string|number)[]> = {
  [K in T[number]]: K
}

2.4 First of Array

题目

type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
]

type errors = [
  // @ts-expect-error
  First<'notArray'>,
  // @ts-expect-error
  First<{ 0: 'arrayLike' }>,
]

题目解析

就是取数组的第一个项,TS中也提供了可以利用指定索引的方式获取,T[0]所以就可以写成

type First<T extends any[]> = T[0]

但是有一个case不满足Expect<Equal<First<[]>, never>>, 原因是空数组取0的时候会报错,所以需要对空数组的情况做特殊处理 返回never。这就需要语法能够判断数组是空数组。TS中提供了extends来进行条件判断。

SomeType extends OtherType ? TrueType : FalseType;

当左侧的类型extends可分配给右侧的类型时,将返回第一个分支的代码,否则返回第二个分支的代码。T extends [] 就可以判断是否是[]。

type First<T extends any[]> = T extends [] ? never : T[0]

2.5 Length of Tuple

题目

const tesla = ['tesla', 'model 3', 'model X', 'model Y'] as const
const spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT'] as const

type cases = [
  Expect<Equal<Length<typeof tesla>, 4>>,
  Expect<Equal<Length<typeof spaceX>, 5>>,
  // @ts-expect-error
  Length<5>,
  // @ts-expect-error
  Length<'hello world'>,
]

题目解析

可以分析出约束是 只读的数组,然后该数组的长度,TS中提供了length属性,所以可以直接

type Length<T extends readonly any[]> = T['length']

2.6 Exclude

题目

type cases = [
  Expect<Equal<MyExclude<'a' | 'b' | 'c', 'a'>, 'b' | 'c'>>,
  Expect<Equal<MyExclude<'a' | 'b' | 'c', 'a' | 'b'>, 'c'>>,
  Expect<Equal<MyExclude<string | number | (() => void), Function>, string | number>>,
]

题目解析

从第一个参数中把能赋值给第二个参数的类型排除,所以也是用条件语句进行判断,在TS中,当条件类型作用于泛型类型时,它们在给定联合类型时变得可分配, 例如:

type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// 结果 type StrArrOrNumArr = string[] | number[] 而不是 (string | number)[]

所以是会对联合类型中的每一项进行判断,返回他们的联合类型。所以对于这题我们可以写成

type MyExclude<T, U> = T extends U ? never: T;

为什么把不要的写成never呢? 因为never是所有类型的子类型,所以对于联合类型中的never就会被忽略,所以就用这一特性可以删除元素。

2.7 Awaited

题目

type X = Promise<string>
type Y = Promise<{ field: number }>
type Z = Promise<Promise<string | number>>
type Z1 = Promise<Promise<Promise<string | boolean>>>

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>>,
]

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

题目解析

分析出参数约束是Promise类型,返回值是Promise的返回值 但是是可以嵌套的,涉及到了新的语法,在条件判断中进行推断,使用关键字 infer, 它使得我们可以获取指定位置的类型。 看本题的答案

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

我们需要获取到Promise的返回值类型 所以用T extends Promise<infer P> 把T展开,然后用infer 推断这里的类型是P,这样我们后边就可以使用它。如果是一层Promise则直接返回P就可以了。但是P可能仍然是Promise类型,所以需要递归的调用MyAwaited方法。

2.8 If

题目

type cases = [
  Expect<Equal<If<true, 'a', 'b'>, 'a'>>,
  Expect<Equal<If<false, 'a', 2>, 2>>,
]

// @ts-expect-error
type error = If<null, 'a', 'b'>

题目解析

分析出接受三个参数,第一个是ture 或者false,如果是true 则返回第二个参数,如果是false则返回第二个参数,

type If<Condition extends boolean, First, Second> = Condition extends true ? First : Second

这里也可以看到其实泛型参数的取名是任意的,只要保持一致即可。

Concat

题目

type cases = [
  Expect<Equal<Concat<[], []>, []>>,
  Expect<Equal<Concat<[], [1]>, [1]>>,
  Expect<Equal<Concat<[1, 2], [3, 4]>, [1, 2, 3, 4]>>,
  Expect<Equal<Concat<['1', 2, '3'], [false, boolean, '4']>, ['1', 2, '3', false, boolean, '4']>>,
]

题目解析

要实现的就是数组中的concat方法,在类型编程中TS也支持对数组使用扩展运算符...,所以就可以写成

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

Includes

题目

type cases = [
  Expect<Equal<Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Kars'>, true>>,
  Expect<Equal<Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'>, false>>,
  Expect<Equal<Includes<[1, 2, 3, 5, 6, 7], 7>, true>>,
  Expect<Equal<Includes<[1, 2, 3, 5, 6, 7], 4>, false>>,
  Expect<Equal<Includes<[1, 2, 3], 2>, true>>,
  Expect<Equal<Includes<[1, 2, 3], 1>, true>>,
  Expect<Equal<Includes<[{}], { a: 'A' }>, false>>,
  Expect<Equal<Includes<[boolean, 2, 3, 5, 6, 7], false>, false>>,
  Expect<Equal<Includes<[true, 2, 3, 5, 6, 7], boolean>, false>>,
  Expect<Equal<Includes<[false, 2, 3, 5, 6, 7], false>, true>>,
  Expect<Equal<Includes<[{ a: 'A' }], { readonly a: 'A' }>, false>>,
  Expect<Equal<Includes<[{ readonly a: 'A' }], { a: 'A' }>, false>>,
  Expect<Equal<Includes<[1], 1 | 2>, false>>,
  Expect<Equal<Includes<[1 | 2], 1>, false>>,
  Expect<Equal<Includes<[null], undefined>, false>>,
  Expect<Equal<Includes<[undefined], null>, false>>,
]

题目解析

判断第二个参数是否在数组中,思路是遍历数组中的每一项进行判断,通过前面的练习,我们可以把数组变成联合类型然后利用extends的分配规则可以遍历的判断,按照该思路,可以写成

type Includes<T extends any[], U> = T extends any[] ? U extends T[number] ? true : false : never

发现有部分没有符合,例如Expect<Equal<Includes<[boolean, 2, 3, 5, 6, 7], false>, false>>, 按照我们写的计算出来的是true, 因为false可以赋值给boolean。所以通过extends进行判断不能够精确的判断是否相等。 但是@type-challenges/utils中提供了Equal方法,我们可以直接使用,看一下它的内部实现

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

利用了把他们放到了相同的位置上然后判断是否能够得到相同的结果来进行判断。 剩下来的就是如果判断数组中的每一项,实现如下:

type Includes<T extends readonly any[], U> = T extends [infer X, ...infer Rest] ? Equal<U, X> extends true ? true : Includes<Rest, U> : false

[infer X, ...infer Rest] 利用解构语法每次拿到数组中的第一项进行判断,如果不相等,递归的调用剩下的数组进行判断。最后[] extends [infer X, ...infer Rest] 会进入false分支。

Push

题目

type cases = [
  Expect<Equal<Push<[], 1>, [1]>>,
  Expect<Equal<Push<[1, 2], '3'>, [1, 2, '3']>>,
  Expect<Equal<Push<['1', 2, '3'], boolean>, ['1', 2, '3', boolean]>>,
]

题目解析

实现数组的push方法,利用解构赋值

type Push<T extends any[], P> = [...T, P]

Unshift

题目

type cases = [
  Expect<Equal<Unshift<[], 1>, [1]>>,
  Expect<Equal<Unshift<[1, 2], 0>, [0, 1, 2]>>,
  Expect<Equal<Unshift<['1', 2, '3'], boolean>, [boolean, '1', 2, '3']>>,
]

题目解析

实现数组的unshift方法,同理利用解构赋值

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

Parameters

题目

const foo = (arg1: string, arg2: number): void => {}
const bar = (arg1: boolean, arg2: { a: 'A' }): void => {}
const baz = (): void => {}

type cases = [
  Expect<Equal<MyParameters<typeof foo>, [string, number]>>,
  Expect<Equal<MyParameters<typeof bar>, [boolean, { a: 'A' }]>>,
  Expect<Equal<MyParameters<typeof baz>, []>>,
]

题目解析

返回函数的所有参数,利用infer推断

type MyParameters<T> = T extends (...args: infer X) => any ? X : never

3 总结

本文以type-challenges中easy类题目为线索,讲解TypeScript中的类型编程,分享了把type当函数把泛型当参数的思想,做了每道题的分析,讲解了keyof、in、extends、infer、扩展运算符等等在题目中的运用。

  • 如果觉得有用请帮忙点个赞🙏。
  • 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿