Skip to content

13 真实案例说明类型编程的意义

我们学了类型编程的各种套路,写了很多高级类型,也学了 TypeScript 内置的高级类型,对类型编程这一块算是有一定程度的掌握了。

那么类型编程在实际开发中会用到么?它的意义是什么呢?这节我们就通过一些案例来说明类型编程有什么用。

类型编程的意义

ts 基础是学习怎么给 js 代码声明各种类型,比如索引类型、函数类型、数组类型等,但是如果需要动态生成一些类型,或者对类型做一些变化呢?

这就是类型编程做的事情了,类型编程可以动态生成类型,对已有类型做修改。

类型编程是对类型参数做一系列运算之后产生新的类型。需要动态生成类型的场景必然会用到类型编程,比如返回值的类型和参数的类型有一定的关系,需要经过计算才能得到。

有的情况下不用类型编程也行,比如返回值可以是一个字符串类型 string,但用了类型编程的话,可能能更精确的提示出是什么 string,也就是具体的字符串字面量类型,那类型提示的精准度自然就提高了一个级别,体验也会更好。

这就是类型编程的意义:需要动态生成类型的场景,必然要用类型编程做一些运算。有的场景下可以不用类型编程,但是用了能够有更精准的类型提示和检查。

我们还是通过例子来说明:

ParseQueryString

前面我们实现了一个复杂的高级类型 ParseQueryString,用到了提取、构造、递归的套路。

这么复杂的高级类型能用在哪里呢?有什么意义呢?想必很多同学都有疑问,那么我们就先聊一下这个高级类型的应用场景。

首先,我们写一个 JS 函数,实现对 query string 的 parse,如果有同名的参数就合并,大概实现是这样的:

ts
function parseQueryString(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;
}

这种逻辑大家写的很多,就不过多解释了:

image

如果要给这个函数加上类型,大家会怎么加呢?

大部分人会这么加:

image

参数是 string 类型,返回值是 parse 之后的对象类型 object。

这样是可以的,而且 object 还可以写成 Record<string, any>,因为对象是索引类型(索引类型就是聚合多个元素的类型,比如对象、class、数组都是)。

image

Record 前面介绍过,是 TS 内置的一个高级类型,会通过映射类型的语法来生成索引类型:

ts
type Record<K extends string | number | symbol, T> = {
  [P in K]: T;
};

比如传入 'a' | 'b' 作为 key,1 作为 value,就可以生成这样索引类型:

image

所以这里的 Record<string, any> 也就是 key 为 string 类型,value 为任意类型的索引类型,可以代替 object 来用,更加语义化一点:

image

但是不管是返回值类型为 object 还是 Record<string, any> 都存在一个问题:返回的对象不能提示出有哪些属性:

image

对于习惯了 ts 的提示的同学来说,没有提示太不爽了。怎么能让这个函数的返回的类型有提示呢?

这就要用到类型编程了。

我们把函数的类型定义改成这样:

image

声明一个类型参数 Str,约束为 string 类型,函数参数的类型指定是这个 Str,返回值的类型通过对 Str 做类型运算得到,也就是 ParseQueryString。

这个 ParseQueryString 的类型做的事情就是把传入的 Str 通过各种类型运算产生对应的索引类型。

这样返回的类型就有提示了:

image

这里最好通过函数重载的方式来声明类型,不然返回值可能和 ParseQueryString 的返回值类型匹配不上,需要 as any 才行,那样比较麻烦。

这里的 ParseQueryString 就是前面实现的那个高级类型,在这里可以用来实现更精准的类型提示,这就是类型体操的意义。

这个类型的实现思路可以看顺口溜那节,就不赘述了:

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

type MergeValues<One, Other> = One extends Other ? One : Other extends unknown[] ? [One, ...Other] : [One, Other];

type MergeParams<OneParam extends Record<string, any>, OtherParam extends Record<string, any>> = {
  readonly [Key in keyof OneParam | keyof OtherParam]: Key extends keyof OneParam
    ? Key extends keyof OtherParam
      ? MergeValues<OneParam[Key], OtherParam[Key]>
      : 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>;
function parseQueryString(queryStr: string) {
  if (!queryStr || !queryStr.length) {
    return {};
  }
  const queryObj: Record<string, 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;
}

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

这里的实现和之前那个还是有一些区别的,主要是这里:

image

当提取 a=1 中的 key 和 value,构造成索引类型的时候,如果提取不出来,之前返回的是空对象,现在改成了 Record<string, any>。

因为 ParseQueryString 是针对字符串字面量类型做运算的,如果传入的不是字面量类型,而是 string,那就会走到这里,如果返回空对象,那取它的任何属性都会报错。

image

所以要把不满足条件时返回的类型改为 Record<string, any>:

image

试一下

对比下用类型编程和不用类型编程的体验:

image

vs

image

这就是类型体操的意义之一:实现更精准的类型提示和检查。

Promise.all

前面提到过,需要动态生成类型的场景,必然会用到类型编程,我们来看个例子。

Promise 的 all 和 race 方法的类型声明是这样的:

ts
interface PromiseConstructor {
  all<T extends readonly unknown[] | []>(
    values: T
  ): Promise<{
    -readonly [P in keyof T]: Awaited<T[P]>;
  }>;

  race<T extends readonly unknown[] | []>(values: T): Promise<Awaited<T[number]>>;
}

因为 Promise.all 是等所有 promise 执行完一起返回,Promise.race 是有一个执行完就返回。返回的类型都需要用到参数 Promise 的 value 类型:

image

image

所以自然要用类型编程来提取出 Promise 的 value 的类型,构造成新的 Promise 类型。

具体来看下这两个类型定义:

ts
interface PromiseConstructor {
  all<T extends readonly unknown[] | []>(
    values: T
  ): Promise<{
    -readonly [P in keyof T]: Awaited<T[P]>;
  }>;
}

类型参数 T 是待处理的 Promise 数组,约束为 unknown[] 或者空数组 []。

这个类型参数 T 就是传入的函数参数的类型。

返回一个新的数组类型,也可以用映射类型的语法构造个新的索引类型(class、对象、数组等聚合多个元素的类型都是索引类型)。

新的索引类型的索引来自之前的数组 T,也就是 P in keyof T,值的类型是之前的值的类型,但要做下 Promise 的 value 类型提取,用内置的高级类型 Awaited,也就是 Awaited<T[P]>。

同时要把 readonly 的修饰去掉,也就是 -readonly。

这就是 Promise.all 的类型定义。因为返回值的类型和参数的类型是有关联的,所以必然会用到类型编程。

Promise.race 的类型定义也是这样:

ts
interface PromiseConstructor {
  race<T extends readonly unknown[] | []>(values: T): Promise<Awaited<T[number]>>;
}

类型参数 T 是待处理的参数的类型,约束为 unknown[] 或者空数组 []。

返回值的类型可能是传入的任何一个 Promise 的 value 类型,那就先取出所有的 Promise 的 value 类型,也就是 T[number]。

因为数组类型也是索引类型,所以可以用索引类型的各种语法。

image

用 Awaited 取出这个联合类型中的每一个类型的 value 类型,也就是 Awaited<T[number]>,这就是 race 方法的返回值的类型。

同样,因为返回值的类型是由参数的类型做一些类型运算得到的,也离不开类型编程。

试一下

这里 T 的类型约束为什么是 unknown[] | [] 也要专门讲一下:

ts 里有个 as const 的语法,加上之后,ts 就会推导出常量字面量类型,否则推导出对应的基础类型:

没有 as const 时:

image

加上 as const 后:

image

没有 as const 时:

image

加上 as const 后:

image

这里类型参数 T 是通过 js 函数的参数传入的,然后取 typeof,也会遇到 as const 的这个问题,约束为 unknown[] | [] 就是 as const 的意思。

image

image

这个地方确实比较特殊,要记一下。

试一下

currying

做了一个参数类型和返回值类型有关系的案例,再来看一个更复杂点的:

有这样一个 curring 函数,接受一个函数,返回柯里化后的函数。

也就是当传入的函数为:

ts
const func = (a: string, b: number, c: boolean) => {};

返回的函数应该为:

ts
(a: string) => (b: number) => (c: boolean) => void

JS 怎么实现不用关注,我们只关注这个 curring 函数的类型怎么定义:

ts
declare function currying(fn: xxx): xxx;

明显,这里返回值类型和参数类型是有关系的,所以要用类型编程。

传入的是函数类型,可以用模式匹配提取参数和返回值的类型来,构造成新的函数类型返回。

每有一个参数就返回一层函数,具体层数是不确定的,所以要用递归。

那么,这个类型的定义就是这样的:

ts
type CurriedFunc<Params, Return> = Params extends [infer Arg, ...infer Rest]
  ? (arg: Arg) => CurriedFunc<Rest, Return>
  : never;

declare function currying<Func>(
  fn: Func
): Func extends (...args: infer Params) => infer Result ? CurriedFunc<Params, Result> : never;

curring 函数有一个类型参数 Func,由函数参数的类型指定。

返回值的类型要对 Func 做一些类型运算,通过模式匹配提取参数和返回值的类型,传入 CurriedFunc 来构造新的函数类型。

构造的函数的层数不确定,所以要用递归,每次提取一个参数到 infer 声明的局部变量 Arg,其余参数到 infer 声明的局部变量 Rest。

用 Arg 作为构造的新的函数函数的参数,返回值的类型继续递归构造。

这样就递归提取出了 Params 中的所有的元素,递归构造出了柯里化后的函数类型。

image

试一下

这个柯里化的函数类型定义,因为返回值的类型和参数的类型是有关系的,所以离不开类型编程。

总结

类型编程是对类型参数做一系列类型运算,产生新的类型。需要对已有类型做修改,需要动态生成类型的场景,必然会用到类型编程,比如 Promise.all、Promise.race、柯里化等场景。

有的时候不用类型编程也行,但用了类型编程能够实现更精准的类型提示和检查,比如 parseQueryString 这个函数的返回值。

这就是类型编程或者说类型体操的意义。

本文的案例合并