TypeScript类型系统支持类型编程,即对类型参数进行一系列操作,形成新的类型。 像这样的东西:
type isTwo = T extends 2 ? true: false;
这种类型的编程逻辑可以写得非常复杂,因此被戏称为“类型举重”。
是TS中最强大、最复杂的部分,属于深水区。
很多朋友不知道学习类型编程有什么用,而且对于商业来说似乎也不需要这个。 明天我们看一个具体的例子来体会一下类型击剑的意义。
我们要实现这样一个JS方法:
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;
}
这段代码很容易看出它正在做查询字符串解析,并将'a=1&b=2&c=3'的字符串解析为{a:1,b:2,c:3}并返回。 如果存在同名的key,就会合并成一个链表。
你写了很多JS逻辑,这部分很容易理解:
那么如果你想给这个函数添加一个类型,你会如何添加呢?
我想,大多数人都会这样添加:
参数为string类型,返回值为解析后的object类型对象。
这是可以的,但是object也可以写成Recordtypescript数组声明,因为object是索引类型(索引类型是聚合多个元素的类型,比如对象、类、数组)。
Record是TS外部的中间类型,它通过映射类型的句型来生成索引类型:
type Record<K extends string | number | symbol, T> = {
[P in K]: T;
}
例如,传入 'a'|'b' 作为键,1 作为值,就可以生成这样的索引类型:
所以,这里的Record是指key是字符串类型,value是任意类型的索引类型。 它可以用来代替对象,并且更语义化:
但是,无论返回值类型是object还是Record,都存在一个问题:返回的对象无法提示它有什么属性:
对于习惯了ts提示的朋友来说,没有提示实在是太难受了。 如何暗示该函数的返回类型?
这就是类型编程的用武之地。
我们将函数的类型定义更改为:
声明一个类型参数Str,约束为字符串类型,指定函数参数的类型为这个Str,通过对Str进行类型操作得到返回值的类型,即ParseQueryString。
这个ParseQueryString类型所做的就是通过对传入的Str进行各种类型的操作,形成对应的索引类型。
这样,返回的类型就有了提示:
是不是很神奇! 这就是式击剑的魅力! 以达到更精确的类型提示。
那么ParseQueryString的中间类型是如何实现的呢?
虽然我们已经实现了:,但是我们再来说一下:
首先,我们需要根据&将‘a=1&b=2&c=3’的字符串分开,使用模式匹配的方法。
再次处理提取的字符串a=1、b=2和c=3,并将结果合并并返回。
那是:
type ParseQueryString<Str extends string>
= Str extends `${infer Param}&${infer Rest}`
? MergeParams<ParseParam, ParseQueryString>
: ParseParam;
类型参数Str是要处理的字符串的类型。 通过模式匹配提取和分割的字符串放入 infer 声明的局部变量 Param 中,其余部分放入 infer 声明的局部变量 Rest 中。
再次处理提取出来的Param,即ParseParam
,剩下的递归处理,即ParseQueryString,然后合并结果。
如果不满足模式匹配,则说明没有&,则处理剩下的部分并返回。
这里的ParseParam是对字符串a=1,b=2,c=3进行处理,也是通过模式匹配的方式提取出来的:
type ParseParam<Param extends string> =
Param extends `${infer Key}=${infer Value}`
? {
[K in Key]: Value
} : Record<string, any>;
类型参数Param是要处理的字符串typescript数组声明,通过模式匹配将=分隔的字符串提取到局部变量Key和Value中,并构造为索引类型返回,
如果不满足模式匹配,则说明不是用=分隔的字符串文字类型,返回Record表示任意索引类型。
测试下:
后续多个索引类型的合并就是通过映射类型的句型构造一个新的索引类型:
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]
: Key extends keyof OtherParam
? OtherParam[Key]
: never
}
类型参数 OneParam 和 OtherParam 是两种索引类型,受 Record 约束。
通过映射类型的句子类型构造一个新的索引类型返回,Key来自两者的组合,即KeyinkeyofOneParam|keyofOtherParam。 并添加readonly修改,使得返回的索引类型无法更改。
应该判断其价值。 如果是两者的值,则合并,否则分别取对应的值。
合并逻辑如下:
type MergeValues =
One extends Other
? One
: Other extends unknown[]
? [One, ...Other]
: [One, Other];
类型参数One和Other是要合并的两个值的类型。 如果两者相同,则返回其中之一。 否则,如果是链表,则合并字段,即[One,...Other]。 否则,合并两个值。 进入一个链表[一个,其他]。
这样就完成了两种索引类型的合并,测试一下:
整体测试下:
有效! 我们实现了 ParseQueryString 的中级类型! (如果不懂类型举重,可以看我的小册子《TypeScript 类型举重秘笈》补基础)
其实这只是纯粹的类型防护,最终目的是在JS中使用它,所以我们将parseQueryString的类型定义改为这样:
将函数参数的类型传递给ParseQueryString中间类型进行类型运算,将返回结果作为函数返回值的类型。 (这里需要使用asany来判断返回值为any,由于默认推导的类型不准确,所以我们使用根据Str动态计算出的类型)
这还可以实现精确的类型提示:
但由于我们的只读限制,该属性的值无法更改:
与没有任何类型的围栏进行对比:
可以推断:
类型编程可以通过类型操作形成更准确的类型,并配合编辑器进行更准确的类型提示和检测。 这就是类型击剑的意义。
总结
类型编程是 TypeScript 的深水内容。 就是对类型进行一系列的类型操作,形成新的类型。 它可以实现更准确的类型提示和检测。
通过parseQueryString函数的类型定义,我们可以直观地体验到使用类型防护和不使用类型防护的区别。 在类型提示方面,体验截然不同。
实现更准确的类型提示和检测,这就是类型防护的意义!