13-真实案例说明类型编程的意义
课程
1
如何阅读本小册
已学完
学习时长: 2分6秒
2
为什么说 TypeScript 的火爆是必然?
已学完
学习时长: 4分28秒
3
TypeScript 类型编程为什么被叫做类型体操?
已学完
学习时长: 9分16秒
4
TypeScript 类型系统支持哪些类型和类型运算?
已学完
学习时长: 19分27秒
5
套路一:模式匹配做提取
已学完
学习时长: 41分32秒
6
套路二:重新构造做变换
已学完
学习时长: 39分51秒
7
套路三:递归复用做循环
学习时长: 42分22秒
8
套路四:数组长度做计数
学习时长: 27分58秒
9
套路五:联合分散可简化
学习时长: 19分36秒
10
套路六:特殊特性要记清
学习时长: 36分41秒
11
类型体操顺口溜
学习时长: 27分26秒
12
TypeScript 内置的高级类型有哪些?
学习时长: 31分59秒
13
真实案例说明类型编程的意义
学习时长: 34分36秒
14
类型编程综合实战一
学习时长: 25分55秒
15
类型编程综合实战二
学习时长: 29分9秒
16
新语法 infer extends 是如何简化类型编程的
学习时长: 11分14秒
17
原理篇:逆变、协变、双向协变、不变
学习时长: 11分31秒
18
原理篇:编译 ts 代码用 tsc 还是 babel?
学习时长: 19分49秒
19
原理篇:实现简易 TypeScript 类型检查
学习时长: 47分51秒
20
原理篇:如何阅读 TypeScript 源码
学习时长: 19分27秒
21
原理篇:一些特殊情况的说明
已学完
学习时长: 12分39秒
22
小册总结
学习时长: 2分32秒
23
加餐:3 种类型来源和 3 种模块语法
学习时长: 17分6秒
24
加餐:用 Project Reference 优化 tsc 编译性能
已学完
学习时长: 4分47秒
juejin_logo copyCreated with Sketch.

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

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

类型编程的意义

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

这就是类型编程做的事情了,类型编程可以动态生成类型,对类型做增删改,比直接声明类型更高一层。

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

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

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

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

ParseQueryString

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

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

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

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;
}

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

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

大部分人会这么加:

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

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

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

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

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

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

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

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

这就要用到类型编程了。

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

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

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

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

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

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

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> {
    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');

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

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

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

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

试一下

有同学可能会发现我们用 as any 来对返回值类型做了断言,这是因为 ParseQueryString<Str> 要传入类型参数 Str 才能知道具体的类型,而具体传入什么在类型检查时是不知道的,所以这里要 as any 才能通过类型检查。

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

vs

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

Promise.all

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

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

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 类型:

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

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

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 的类型定义也是这样:

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

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

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

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

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

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

试一下

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

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

没有 as const 时:

加上 as const 后:

没有 as const 时:

加上 as const 后:

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

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

试一下

currying

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

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

也就是当传入的函数为:

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

返回的函数应该为:

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

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

declare function currying(fn: xxx): xxx;

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

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

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

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

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 中的所有的元素,递归构造出了柯里化后的函数类型。

试一下

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

总结

类型编程比直接声明类型更高一层,他是对类型参数做一系列类型运算,产生新的类型。需要动态生成类型的场景,必然会用到类型编程,比如 Promise.all、Promise.race、柯里化等场景。

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

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

本文的案例合并

留言
Ctrl + Enter
全部评论(31)
hedgehog_boy的头像
删除
隔了一周没看,前面的又忘了,新的显得又复杂了
1
回复
薄墨无痕的头像
删除
FE @ 数字游民
const str:string = 'a=1&b=2&c=3';
const res = parseQueryString(str);
这个地方给添加一个 string的标识,就失去类型提示了 这个是为什么呀
如果 是const str = 'a=1&b=2&c=3'; 就仍然是可以的
点赞
2
删除
(作者)
因为默认推导出的是字面量类型呀,你加上 string 推导的就是 string 了
点赞
回复
删除
懂了 谢谢啦 下午的时候有点晕掉了
因为默认推导出的是字面量类型呀,你加上 string 推导的就是 string 了
点赞
回复
江南小树林呀的头像
删除
coder
光哥,这里错了,应该是Return
点赞
回复
程序员紫菜苔的头像
删除
最后的never应该是Return吧
2
4
删除
(作者)
你试一下就知道了
点赞
回复
删除
我觉得楼上说的对
你试一下就知道了
点赞
回复
查看更多回复
坚持前端求学道路的小白的头像
删除
这里是写错了吗?还是我理解错了?
点赞
回复
CoderStan的头像
删除
前端开发
请问一下,Promise.all 的【T extends readonly unknown[] | []】加不加【| []】有什么区别 unkonwn[] 应该是包括了 [] 的,但是加了 [] 就会得到 [number, number],不加 [] 就只能得到 number[],这是为什么?
点赞
1
删除
文章中有说,建议再看一遍
点赞
回复
爱吃鱼的桶哥Z的头像
删除
伪 · 全栈打杂攻城狮
这里打错字了。
点赞
1
删除
(作者)
好的
点赞
回复
零式大人的头像
删除
普通上班族
Promise.all 的 T 约束条件如果写作 T extends unknown[] ( 没有 | [] ) 得到的是一个数组类型,所以约束语句后面的 `| []` 是约束为元祖的意思吗
点赞
1
删除
(作者)
promise.all 的参数就是数组
点赞
回复
Col0ring的头像
删除
前端职业搬砖手 @ 搬砖
其实我感觉ParseQueryString 的例子没有ParseUrlParams 好,url 解析在实际操作中都是动态的 string 类型,这个方法其实没有啥实际的运用场景。ParseUrlParams 还可以解析配置路由的动态参数,可以直接带入到实际开发中感受,感官会不会更好一点。
1
2
删除
(作者)
是这样,这里举的例子不是很好
点赞
回复
删除
这个ParseQueryString这么大坨,个人感觉说真的还不容直接object[偷笑],不值得
是这样,这里举的例子不是很好
点赞
回复
xkux8023的头像
删除
实力不够,这两章节开始看的相当吃力了
1
1
删除
(作者)
有代码呀,照着敲一敲就容易懂了
1
回复
YibuMe的头像
删除
Node.js探索者
Promise.all 返回的是数组类型,为什么可以返回这个索引类型 Promise<{
-readonly [P in keyof T]: Awaited<T[P]>;
}> 来代替呢?
点赞
3
删除
(作者)
数组类型也是索引类型
点赞
回复
删除
我也疑惑这样写为什么结果没有变成 {0:number,1:number,2:number} 而是 [number,number,number]
数组类型也是索引类型
点赞
回复
查看更多回复
lxb不想说话的头像
删除
666666
点赞
回复
BarrySong4Real的头像
删除
一个路过的前端 @ 浑水摸鱼
6666
点赞
回复
Loadings的头像
删除
前端开发
这一章的内容看着好像说明不是很多的样子
点赞
1
删除
(作者)
小册每节字数控制在 3000 以内,所以就保留了三个,其实还准备了几个的,放到综合实战讲吧
点赞
回复
阿五的头像
删除
前端打杂 @ 「程序员菜馆」公众号作者
太强了
点赞
回复