Summary of 9 types of operations and 4 types of routines in type gymnastics

The theme shared with you today is to do type gymnastics together.

It is mainly divided into 4 parts for introduction:

  1. The background of type gymnastics, through the background to understand why to include type gymnastics in the project;
  2. Understand the main types, operation logic, and type routines of type gymnastics;
  3. Type gymnastics practice, parsing TypeScript built-in advanced types, handwriting ParseQueryString complex types;
  4. Summarize, share the above, and precipitate the conclusion.

1. Background

In the background chapter, what is type, what is type safety, how to achieve type safety, and what is type gymnastics?

to understand the meaning of type gymnastics.

1. What is a type?

Before understanding what a type is, let's introduce two concepts:

  • Different types of variables occupy different memory sizes

Variables of type boolean will allocate 4 bytes of memory, while variables of type number will allocate 8 bytes of memory. Declaring different types for variables means that they will occupy different memory spaces.

  • Different types of variables can do different operations

The number type can perform operations such as addition, subtraction, multiplication and division, but boolean cannot. Different types of objects in the composite type have different methods, such as Date and RegExp. Different types of variables mean different operations on the variable.

To sum up, a simple conclusion can be drawn that a type is an abstract definition of different content provided by a programming language .

2. What is type safety?

After understanding the concept of types, then, what is type safety?

A simple definition is that type safety is doing only what the type allows. For example, for boolean type, addition, subtraction, multiplication and division are not allowed, only true and false are allowed.

When we can achieve type safety, we can greatly reduce potential problems in the code and greatly improve the quality of the code.

3. How to achieve type safety?

So, how to achieve type safety?

Two types of type checking mechanisms are introduced here, namely dynamic type checking and static type checking.

3.1 Dynamic type checking

Javascript is a typical dynamic type check. It has no type information at compile time, and only checks at runtime, resulting in many hidden bugs.

3.2 静态类型检查

TypeScript 作为 Javascript 的超集,采用的是静态类型检查,在编译时就有类型信息,检查类型问题,减少运行时的潜在问题。

4. 什么是类型体操

上面介绍了类型的一些定义,都是大家熟悉的一些关于类型的背景介绍,这一章节回归到本次分享的主题概念,类型体操。

了解类型体操前,先介绍 3 种类型系统。

4.1 简单类型系统

简单类型系统,它只基于声明的类型做检查,比如一个加法函数,可以加整数也可以加小数,但在简单类型系统中,需要声明 2 个函数来做这件事情。

int add(int a, int b) {
    return a + b
}

double add(double a, double b) {
    return a + b
}

4.2 泛型类型系统

泛型类型系统,它支持类型参数,通过给参数传参,可以动态定义类型,让类型更加灵活。

T add<T>(T a, T b) {
    return a + b
}

add(12)
add(1.12.2)

但是在一些需要类型参数逻辑运算的场景就不适用了,比如一个返回对象某个属性值的函数类型。

function getPropValue<T>(obj: T, key{
  return obj[key]
}

4.3 类型编程系统

类型编程系统,它不仅支持类型参数,还能给类型参数做各种逻辑运算,比如上面提到的返回对象某个属性值的函数类型,可以通过 keyof、T[K] 来逻辑运算得到函数类型。

function getPropValue<
  T extends object
  Key extends keyof T
>(obj: T, key: Key): T[Key
{
  return obj[key]
}

总结上述,类型体操就是类型编程,对类型参数做各种逻辑运算,以产生新的类型

之所以称之为体操,是因为它的复杂度,右侧是一个解析参数的函数类型,里面用到了很多复杂的逻辑运算,等先介绍了类型编程的运算方法后,再来解析这个类型的实现。

二、了解类型体操

熟悉完类型体操的概念后,再来继续了解类型体操有哪些类型,支持哪些运算逻辑,有哪些运算套路。

1. 有哪些类型

类型体操的主要类型列举在图中。TypeScript 复用了 JS 的基础类型和复合类型,并新增元组(Tuple)、接口(Interface)、枚举(Enum)等类型,这些类型在日常开发过程中类型声明应该都很常用,不做赘述。

// 元组(Tuple)就是元素个数和类型固定的数组类型
type Tuple = [number, string];

// 接口(Interface)可以描述函数、对象、构造器的结构:
interface IPerson {
    name: string;
    age: number;
}

class Person implements IPerson {
    name: string;
    age: number;
}

const obj: IPerson = {
    name'aa',
    age18
}

// 枚举(Enum)是一系列值的复合:
enum Transpiler {
    Babel = 'babel',
    Postcss = 'postcss',
    Terser = 'terser',
    Prettier = 'prettier',
    TypeScriptCompiler = 'tsc'
}

const transpiler = Transpiler.TypeScriptCompiler;

2. 运算逻辑

重点介绍的是类型编程支持的运算逻辑。

TypeScript 支持条件、推导、联合、交叉、对联合类型做映射等 9 种运算逻辑。

  • 条件:T extends U ? X : Y

条件判断和 js 逻辑相同,都是如果满足条件就返回 a 否则返回 b。

// 条件:extends ? :
// 如果 T 是 2 的子类型,那么类型是 true,否则类型是 false。
type isTwo<T> = T extends 2 ? true : false;
// false
type res = isTwo<1>;
  • 约束:extends

通过约束语法 extends 限制类型。

// 通过 T extends Length 约束了 T 的类型,必须是包含 length 属性,且 length 的类型必须是 number。
interface Length {
    length: number
}

function fn1<T extends Length>(arg: T): number{
    return arg.length
}
  • 推导:infer

推导则是类似 js 的正则匹配,都满足公式条件时,可以提取公式中的变量,直接返回或者再次加工都可以。

// 推导:infer
// 提取元组类型的第一个元素:
// extends 约束类型参数只能是数组类型,因为不知道数组元素的具体类型,所以用 unknown。
// extends 判断类型参数 T 是不是 [infer F, ...infer R] 的子类型,如果是就返回 F 变量,如果不是就不返回
type First<T extends unknown[]> = T extends [infer F, ...infer R] ? F : never;
// 1
type res2 = First<[123]>;
  • 联合:|

联合代表可以是几个类型之一。

type Union = 1 | 2 | 3
  • 交叉:&

交叉代表对类型做合并。

type ObjType = { a: number } & { c: boolean }
  • 索引查询:keyof T

keyof 用于获取某种类型的所有键,其返回值是联合类型。

// const a: 'name' | 'age' = 'name'
const a: keyof {
    name: string,
    age: number
} = 'name'
  • 索引访问:T[K]

T[K] 用于访问索引,得到索引对应的值的联合类型。

interface I3 {
  name: string,
  age: number
}

type T6 = I3[keyof I3] // string | number

  • 索引遍历: in

in 用于遍历联合类型。

const obj = {
    name'tj',
    age11
}

type T5 = {
    [P in keyof typeof obj]: any
}

/*
{
  name: any,
  age: any
}
*/

  • 索引重映射: as

as 用于修改映射类型的 key。

// 通过索引查询 keyof,索引访问 t[k],索引遍历 in,索引重映射 as,返回全新的 key、value 构成的新的映射类型
type MapType<T> = {
    [
    Key in keyof T
    as `${Key & string}${Key & string}${Key & string}`
    ]: [T[Key], T[Key], T[Key]]
}
// {
//     aaa: [1, 1, 1];
//     bbb: [2, 2, 2];
// }
type res3 = MapType<{ a1b2 }>

3. 运算套路

根据上面介绍的 9 种运算逻辑,我总结了 4 个类型套路。

  • 模式匹配做提取;
  • 重新构造做变换;
  • 递归复用做循环;
  • 数组长度做计数。

3.1 模式匹配做提取

第一个类型套路是模式匹配做提取。

模式匹配做提取的意思是通过类型 extends 一个模式类型,把需要提取的部分放到通过 infer 声明的局部变量里。

举个例子,用模式匹配提取函数参数类型。

type GetParameters<Func extends Function> =
    Func extends (...args: infer Args) => unknown ? Args : never;

type ParametersResult = GetParameters<(name: string, age: number) => string>

首先用 extends 限制类型参数必须是 Function 类型。

然后用 extends 为 参数类型匹配公式,当满足公式时,提取公式中的变量 Args。

实现函数参数类型的提取。

3.2 重新构造做变换

第二个类型套路是重新构造做变换。

重新构造做变换的意思是想要变化就需要重新构造新的类型,并且可以在构造新类型的过程中对原类型做一些过滤和变换。

比如实现一个字符串类型的重新构造。

type CapitalizeStr<Str extends string> =
    Str extends `${infer First}${infer Rest}`
    ? `${Uppercase<First>}${Rest}` : Str;

type CapitalizeResult = CapitalizeStr<'tang'>

首先限制参数类型必须是字符串类型。

然后用 extends 为参数类型匹配公式,提取公式中的变量 First Rest,并通过 Uppercase 封装。

实现了首字母大写的字符串字面量类型。

3.3 递归复用做循环

第三个类型套路是递归复用做循环。

TypeScript 本身不支持循环,但是可以通过递归完成不确定数量的类型编程,达到循环的效果。

比如通过递归实现数组类型反转。

type ReverseArr<Arr extends unknown[]> =
    Arr extends [infer First, ...infer Rest]
    ? [...ReverseArr<Rest>, First]
    : Arr;


type ReverseArrResult = ReverseArr<[12345]>

首先限制参数必须是数组类型。

然后用 extends 匹配公式,如果满足条件,则调用自身,否则直接返回。

实现了一个数组反转类型。

3.4 数组长度做计数

第四个类型套路是数组长度做计数。

类型编程本身是不支持做加减乘除运算的,但是可以通过递归构造指定长度的数组,然后取数组长度的方式来完成数值的加减乘除。

比如通过数组长度实现类型编程的加法运算。

type BuildArray<
    Length extends number,
    Ele = unknown,
    Arr extends unknown[] = []
    > = Arr['length'] extends Length
    ? Arr
    : BuildArray<Length, Ele, [...Arr, Ele]>;

type Add<Num1 extends number, Num2 extends number> =
    [...BuildArray<Num1>, ...BuildArray<Num2>]['length'];


type AddResult = Add<3225>

首先通过递归创建一个可以生成任意长度的数组类型

然后创建一个加法类型,通过数组的长度来实现加法运算。

三、类型体操实践

分享的第三部分是类型体操实践。

前面分享了类型体操的概念及常用的运算逻辑。

下面我们就用这些运算逻辑来解析 TypeScript 内置的高级类型。

1. 解析 TypeScript 内置高级类型

  • partial 把索引变为可选

通过 in 操作符遍历索引,为所有索引添加 ?前缀实现把索引变为可选的新的映射类型。

type TPartial<T> = {
    [P in keyof T]?: T[P];
};

type PartialRes = TPartial<{ name'aa'age18 }>
  • Required 把索引变为必选

通过 in 操作符遍历索引,为所有索引删除 ?前缀实现把索引变为必选的新的映射类型。

type TRequired<T> = {
    [P in keyof T]-?: T[P]
}

type RequiredRes = TRequired<{ name?: 'aa', age?: 18 }>
  • Readonly 把索引变为只读

通过 in 操作符遍历索引,为所有索引添加 readonly 前缀实现把索引变为只读的新的映射类型。

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

type ReadonlyRes = TReadonly<{ name?: 'aa', age?: 18 }>
  • Pick 保留过滤索引

首先限制第二个参数必须是对象的 key 值,然后通过 in 操作符遍历第二个参数,生成新的映射类型实现。

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

type PickRes = TPick<{ name?: 'aa', age?: 18 }, 'name'>
  • Record 创建映射类型

通过 in 操作符遍历联合类型 K,创建新的映射类型。

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

type RecordRes = TRecord<'aa' | 'bb', string>
  • Exclude 删除联合类型的一部分

通过 extends 操作符,判断参数 1 能否赋值给参数 2,如果可以则返回 never,以此删除联合类型的一部分。

type TExclude<T, U> = T extends U ? never : T

type ExcludeRes = TExclude<'aa' | 'bb''aa'>
  • Extract 保留联合类型的一部分

和 Exclude 逻辑相反,判断参数 1 能否赋值给参数 2,如果不可以则返回 never,以此保留联合类型的一部分。

type TExtract<T, U> = T extends U ? T : never

type ExtractRes = TExtract<'aa' | 'bb''aa'>
  • Omit 删除过滤索引

通过高级类型 Pick、Exclude 组合,删除过滤索引。

type TOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

type OmitRes = TOmit<{ name'aa'age18 }, 'name'>
  • Awaited 用于获取 Promise 的 valueType

通过递归来获取未知层级的 Promise 的 value 类型。

type TAwaited<T> =
    T extends null | undefined
        ? T
        : T extends object & { then(onfulfilled: infer F): any }
            ? F extends ((value: infer V, ...args: any) => any)
                ? Awaited<V>
                : never
            : T;


type AwaitedRes = TAwaited<Promise<Promise<Promise<string>>>>

还有非常多高级类型,实现思路和上面介绍的类型套路大多一致,这里不一一赘述。

2. 解析 ParseQueryString 复杂类型

重点解析的是在背景章节介绍类型体操复杂度,举例说明的解析字符串参数的函数类型。

如图示 demo 所示,这个函数是用于将指定字符串格式解析为对象格式。

function parseQueryString1(queryStr{
  if (!queryStr || !queryStr.length) {
    return {}
  }
  const queryObj = {}
  const items = queryStr.split('&')
  items.forEach((item) => {
    const [key, value] = item.split('=')
    if (queryObj[key]) {
      if (Array.isArray(queryObj[key])) {
        queryObj[key].push(value)
      } else {
        queryObj[key] = [queryObj[key], value]
      }
    } else {
      queryObj[key] = value
    }
  })
  return queryObj
}

比如获取字符串 a=1&b=2 中 a 的值。

常用的类型声明方式如下图所示:

function parseQueryString1(queryStr: string): Record<stringany{
  if (!queryStr || !queryStr.length) {
    return {}
  }
  const queryObj = {}
  const items = queryStr.split('&')
  items.forEach((item) => {
    const [key, value] = item.split('=')
    if (queryObj[key]) {
      if (Array.isArray(queryObj[key])) {
        queryObj[key].push(value)
      } else {
        queryObj[key] = [queryObj[key], value]
      }
    } else {
      queryObj[key] = value
    }
  })
  return queryObj
}

参数类型为 string,返回类型为 Record<string, any>,这时看到,res1.a 类型为 any,那么有没有办法,准确的知道 a 的类型是字面量类型 1 呢?

下面就通过类型体操的方式,来重写解析字符串参数的函数类型。

首先限制参数类型是 string 类型,然后为参数匹配公式 a&b,如果满足公式,将 a 解析为 key value 的映射类型,将 b 递归 ParseQueryString 类型,继续解析,直到不再满足 a&b 公式。

最后,就可以得到一个精准的函数返回类型,res.a = 1


type ParseParam<Param extends string> =
    Param extends `${infer Key}=${infer Value}`
        ? {
            [K in Key]: Value
        } : Record<string, any>;

type MergeParams<
    OneParam extends Record<string, any>,
    OtherParam extends Record<string, any>
> = {
  readonly [Key in keyof OneParam | keyof OtherParam]:
    Key extends keyof OneParam
        ? OneParam[Key]
        : Key extends keyof OtherParam
            ? OtherParam[Key]
            : never
}

type ParseQueryString<Str extends string> =
    Str extends `${infer Param}&${infer Rest}`
        ? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
        : ParseParam<Str>;
function parseQueryString<Str extends string>(queryStr: Str): ParseQueryString<Str{
    if (!queryStr || !queryStr.length) {
        return {} as any;
    }
    const queryObj = {} as any;
    const items = queryStr.split('&');
    items.forEach(item => {
        const [key, value] = item.split('=');
        if (queryObj[key]) {
            if(Array.isArray(queryObj[key])) {
                queryObj[key].push(value);
            } else {
                queryObj[key] = [queryObj[key], value]
            }
        } else {
            queryObj[key] = value;
        }
    });
    return queryObj as any;
}


const res = parseQueryString('a=1&b=2&c=3');

console.log(res.a) // type 1

四、小结

综上分享,从 3 个方面介绍了类型体操。

  • 第一点是类型体操背景,了解了什么是类型,什么是类型安全,怎么实现类型安全;

  • 第二点是熟悉类型体操的主要类型、支持的逻辑运算,并总结了 4 个类型套路;

  • 第三点是类型体操实践,解析了 TypeScript 内置高级类型的实现,并手写了一些复杂函数类型。

从中我们了解到需要动态生成类型的场景,必然是要用类型编程做一些运算,即使有的场景下可以不用类型编程,但是使用类型编程能够有更精准的类型提示和检查,减少代码中潜在的问题。

参考资料+源码

这里列举了本次分享的参考资料及示例源码,欢迎大家扩展阅读:

[1]

参考资料《TypeScript 类型体操通关秘籍》: https://juejin.cn/book/7047524421182947366

[2]

示例源码: https://github.com/jiaozitang/ts-demo


本文分享自微信公众号 - 凹凸实验室(AOTULabs)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

{{o.name}}
{{m.name}}

Guess you like

Origin my.oschina.net/o2team/blog/5578754