【TS】1470- 總結(jié) TS 類型體操的9種類型運(yùn)算、4種類型套路

今天給大家分享的主題是一起來做類型體操。
主要分為 4 個(gè)部分進(jìn)行介紹:
類型體操的背景,通過背景了解為什么要在項(xiàng)目中加入類型體操; 了解類型體操的主要類型、運(yùn)算邏輯、和類型套路; 類型體操實(shí)踐,解析 TypeScript 內(nèi)置高級類型,手寫 ParseQueryString復(fù)雜類型;小結(jié),綜上分享,沉淀結(jié)論。
一、背景—
在背景章節(jié)介紹的是什么是類型,什么是類型安全,怎么實(shí)現(xiàn)類型安全,什么是類型體操?
以了解類型體操的意義。
1. 什么是類型?
了解什么是類型之前,先來介紹兩個(gè)概念:
不同類型變量占據(jù)的內(nèi)存大小不同
boolean 類型的變量會(huì)分配 4 個(gè)字節(jié)的內(nèi)存,而 number 類型的變量則會(huì)分配 8 個(gè)字節(jié)的內(nèi)存,給變量聲明了不同的類型就代表了會(huì)占據(jù)不同的內(nèi)存空間。
不同類型變量可做的操作不同
number 類型可以做加減乘除等運(yùn)算,boolean 就不可以,復(fù)合類型中不同類型的對象可用的方法不同,比如 Date 和 RegExp,變量的類型不同代表可以對該變量做的操作就不同。
綜上,可以得到一個(gè)簡單的結(jié)論就是,類型就是編程語言提供對不同內(nèi)容的抽象定義。
2. 什么是類型安全?
了解了類型的概念后,那么,什么是類型安全呢?
一個(gè)簡單的定義就是,類型安全就是只做該類型允許的操作。比如對于 boolean 類型,不允許加減乘除運(yùn)算,只允許賦值 true、false。
當(dāng)我們能做到類型安全時(shí),可以大量的減少代碼中潛在的問題,大量提高代碼質(zhì)量。
3. 怎么實(shí)現(xiàn)類型安全?
那么,怎么做到類型安全?
這里介紹兩種類型檢查機(jī)制,分別是動(dòng)態(tài)類型檢查和靜態(tài)類型檢查。
3.1 動(dòng)態(tài)類型檢查
Javascript 就是典型的動(dòng)態(tài)類型檢查,它在編譯時(shí),沒有類型信息,到運(yùn)行時(shí)才檢查,導(dǎo)致很多隱藏 bug。

3.2 靜態(tài)類型檢查
TypeScript 作為 Javascript 的超集,采用的是靜態(tài)類型檢查,在編譯時(shí)就有類型信息,檢查類型問題,減少運(yùn)行時(shí)的潛在問題。

4. 什么是類型體操
上面介紹了類型的一些定義,都是大家熟悉的一些關(guān)于類型的背景介紹,這一章節(jié)回歸到本次分享的主題概念,類型體操。
了解類型體操前,先介紹 3 種類型系統(tǒng)。
4.1 簡單類型系統(tǒng)
簡單類型系統(tǒng),它只基于聲明的類型做檢查,比如一個(gè)加法函數(shù),可以加整數(shù)也可以加小數(shù),但在簡單類型系統(tǒng)中,需要聲明 2 個(gè)函數(shù)來做這件事情。
int add(int a, int b) {
return a + b
}
double add(double a, double b) {
return a + b
}
4.2 泛型類型系統(tǒng)
泛型類型系統(tǒng),它支持類型參數(shù),通過給參數(shù)傳參,可以動(dòng)態(tài)定義類型,讓類型更加靈活。
T add<T>(T a, T b) {
return a + b
}
add(1, 2)
add(1.1, 2.2)
但是在一些需要類型參數(shù)邏輯運(yùn)算的場景就不適用了,比如一個(gè)返回對象某個(gè)屬性值的函數(shù)類型。
function getPropValue<T>(obj: T, key) {
return obj[key]
}
4.3 類型編程系統(tǒng)
類型編程系統(tǒng),它不僅支持類型參數(shù),還能給類型參數(shù)做各種邏輯運(yùn)算,比如上面提到的返回對象某個(gè)屬性值的函數(shù)類型,可以通過 keyof、T[K] 來邏輯運(yùn)算得到函數(shù)類型。
function getPropValue<
T extends object,
Key extends keyof T
>(obj: T, key: Key): T[Key] {
return obj[key]
}
總結(jié)上述,類型體操就是類型編程,對類型參數(shù)做各種邏輯運(yùn)算,以產(chǎn)生新的類型。
之所以稱之為體操,是因?yàn)樗膹?fù)雜度,右側(cè)是一個(gè)解析參數(shù)的函數(shù)類型,里面用到了很多復(fù)雜的邏輯運(yùn)算,等先介紹了類型編程的運(yùn)算方法后,再來解析這個(gè)類型的實(shí)現(xiàn)。
二、了解類型體操—
熟悉完類型體操的概念后,再來繼續(xù)了解類型體操有哪些類型,支持哪些運(yùn)算邏輯,有哪些運(yùn)算套路。
1. 有哪些類型
類型體操的主要類型列舉在圖中。TypeScript 復(fù)用了 JS 的基礎(chǔ)類型和復(fù)合類型,并新增元組(Tuple)、接口(Interface)、枚舉(Enum)等類型,這些類型在日常開發(fā)過程中類型聲明應(yīng)該都很常用,不做贅述。

// 元組(Tuple)就是元素個(gè)數(shù)和類型固定的數(shù)組類型
type Tuple = [number, string];
// 接口(Interface)可以描述函數(shù)、對象、構(gòu)造器的結(jié)構(gòu):
interface IPerson {
name: string;
age: number;
}
class Person implements IPerson {
name: string;
age: number;
}
const obj: IPerson = {
name: 'aa',
age: 18
}
// 枚舉(Enum)是一系列值的復(fù)合:
enum Transpiler {
Babel = 'babel',
Postcss = 'postcss',
Terser = 'terser',
Prettier = 'prettier',
TypeScriptCompiler = 'tsc'
}
const transpiler = Transpiler.TypeScriptCompiler;
2. 運(yùn)算邏輯
重點(diǎn)介紹的是類型編程支持的運(yùn)算邏輯。
TypeScript 支持條件、推導(dǎo)、聯(lián)合、交叉、對聯(lián)合類型做映射等 9 種運(yùn)算邏輯。
條件: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
}
推導(dǎo):infer
推導(dǎo)則是類似 js 的正則匹配,都滿足公式條件時(shí),可以提取公式中的變量,直接返回或者再次加工都可以。
// 推導(dǎo):infer
// 提取元組類型的第一個(gè)元素:
// extends 約束類型參數(shù)只能是數(shù)組類型,因?yàn)椴恢罃?shù)組元素的具體類型,所以用 unknown。
// extends 判斷類型參數(shù) T 是不是 [infer F, ...infer R] 的子類型,如果是就返回 F 變量,如果不是就不返回
type First<T extends unknown[]> = T extends [infer F, ...infer R] ? F : never;
// 1
type res2 = First<[1, 2, 3]>;
聯(lián)合:|
聯(lián)合代表可以是幾個(gè)類型之一。
type Union = 1 | 2 | 3
交叉:&
交叉代表對類型做合并。
type ObjType = { a: number } & { c: boolean }
索引查詢:keyof T
keyof 用于獲取某種類型的所有鍵,其返回值是聯(lián)合類型。
// const a: 'name' | 'age' = 'name'
const a: keyof {
name: string,
age: number
} = 'name'
索引訪問:T[K]
T[K] 用于訪問索引,得到索引對應(yīng)的值的聯(lián)合類型。
interface I3 {
name: string,
age: number
}
type T6 = I3[keyof I3] // string | number
索引遍歷: in
in 用于遍歷聯(lián)合類型。
const obj = {
name: 'tj',
age: 11
}
type T5 = {
[P in keyof typeof obj]: any
}
/*
{
name: any,
age: any
}
*/
索引重映射: as
as 用于修改映射類型的 key。
// 通過索引查詢 keyof,索引訪問 t[k],索引遍歷 in,索引重映射 as,返回全新的 key、value 構(gòu)成的新的映射類型
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<{ a: 1, b: 2 }>
3. 運(yùn)算套路
根據(jù)上面介紹的 9 種運(yùn)算邏輯,我總結(jié)了 4 個(gè)類型套路。
模式匹配做提取; 重新構(gòu)造做變換; 遞歸復(fù)用做循環(huán); 數(shù)組長度做計(jì)數(shù)。
3.1 模式匹配做提取
第一個(gè)類型套路是模式匹配做提取。
模式匹配做提取的意思是通過類型 extends 一個(gè)模式類型,把需要提取的部分放到通過 infer 聲明的局部變量里。
舉個(gè)例子,用模式匹配提取函數(shù)參數(shù)類型。
type GetParameters<Func extends Function> =
Func extends (...args: infer Args) => unknown ? Args : never;
type ParametersResult = GetParameters<(name: string, age: number) => string>
首先用 extends 限制類型參數(shù)必須是 Function 類型。
然后用 extends 為 參數(shù)類型匹配公式,當(dāng)滿足公式時(shí),提取公式中的變量 Args。
實(shí)現(xiàn)函數(shù)參數(shù)類型的提取。
3.2 重新構(gòu)造做變換
第二個(gè)類型套路是重新構(gòu)造做變換。
重新構(gòu)造做變換的意思是想要變化就需要重新構(gòu)造新的類型,并且可以在構(gòu)造新類型的過程中對原類型做一些過濾和變換。
比如實(shí)現(xiàn)一個(gè)字符串類型的重新構(gòu)造。
type CapitalizeStr<Str extends string> =
Str extends `${infer First}${infer Rest}`
? `${Uppercase<First>}${Rest}` : Str;
type CapitalizeResult = CapitalizeStr<'tang'>
首先限制參數(shù)類型必須是字符串類型。
然后用 extends 為參數(shù)類型匹配公式,提取公式中的變量 First Rest,并通過 Uppercase 封裝。
實(shí)現(xiàn)了首字母大寫的字符串字面量類型。
3.3 遞歸復(fù)用做循環(huán)
第三個(gè)類型套路是遞歸復(fù)用做循環(huán)。
TypeScript 本身不支持循環(huán),但是可以通過遞歸完成不確定數(shù)量的類型編程,達(dá)到循環(huán)的效果。
比如通過遞歸實(shí)現(xiàn)數(shù)組類型反轉(zhuǎn)。
type ReverseArr<Arr extends unknown[]> =
Arr extends [infer First, ...infer Rest]
? [...ReverseArr<Rest>, First]
: Arr;
type ReverseArrResult = ReverseArr<[1, 2, 3, 4, 5]>
首先限制參數(shù)必須是數(shù)組類型。
然后用 extends 匹配公式,如果滿足條件,則調(diào)用自身,否則直接返回。
實(shí)現(xiàn)了一個(gè)數(shù)組反轉(zhuǎn)類型。
3.4 數(shù)組長度做計(jì)數(shù)
第四個(gè)類型套路是數(shù)組長度做計(jì)數(shù)。
類型編程本身是不支持做加減乘除運(yùn)算的,但是可以通過遞歸構(gòu)造指定長度的數(shù)組,然后取數(shù)組長度的方式來完成數(shù)值的加減乘除。
比如通過數(shù)組長度實(shí)現(xiàn)類型編程的加法運(yùn)算。
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<32, 25>
首先通過遞歸創(chuàng)建一個(gè)可以生成任意長度的數(shù)組類型
然后創(chuàng)建一個(gè)加法類型,通過數(shù)組的長度來實(shí)現(xiàn)加法運(yùn)算。
三、類型體操實(shí)踐—
分享的第三部分是類型體操實(shí)踐。
前面分享了類型體操的概念及常用的運(yùn)算邏輯。
下面我們就用這些運(yùn)算邏輯來解析 TypeScript 內(nèi)置的高級類型。
1. 解析 TypeScript 內(nèi)置高級類型
partial 把索引變?yōu)榭蛇x
通過 in 操作符遍歷索引,為所有索引添加 ?前綴實(shí)現(xiàn)把索引變?yōu)榭蛇x的新的映射類型。
type TPartial<T> = {
[P in keyof T]?: T[P];
};
type PartialRes = TPartial<{ name: 'aa', age: 18 }>
Required 把索引變?yōu)楸剡x
通過 in 操作符遍歷索引,為所有索引刪除 ?前綴實(shí)現(xiàn)把索引變?yōu)楸剡x的新的映射類型。
type TRequired<T> = {
[P in keyof T]-?: T[P]
}
type RequiredRes = TRequired<{ name?: 'aa', age?: 18 }>
Readonly 把索引變?yōu)橹蛔x
通過 in 操作符遍歷索引,為所有索引添加 readonly 前綴實(shí)現(xiàn)把索引變?yōu)橹蛔x的新的映射類型。
type TReadonly<T> = {
readonly [P in keyof T]: T[P]
}
type ReadonlyRes = TReadonly<{ name?: 'aa', age?: 18 }>
Pick 保留過濾索引
首先限制第二個(gè)參數(shù)必須是對象的 key 值,然后通過 in 操作符遍歷第二個(gè)參數(shù),生成新的映射類型實(shí)現(xiàn)。
type TPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type PickRes = TPick<{ name?: 'aa', age?: 18 }, 'name'>
Record 創(chuàng)建映射類型
通過 in 操作符遍歷聯(lián)合類型 K,創(chuàng)建新的映射類型。
type TRecord<K extends keyof any, T> = {
[P in K]: T
}
type RecordRes = TRecord<'aa' | 'bb', string>
Exclude 刪除聯(lián)合類型的一部分
通過 extends 操作符,判斷參數(shù) 1 能否賦值給參數(shù) 2,如果可以則返回 never,以此刪除聯(lián)合類型的一部分。
type TExclude<T, U> = T extends U ? never : T
type ExcludeRes = TExclude<'aa' | 'bb', 'aa'>
Extract 保留聯(lián)合類型的一部分
和 Exclude 邏輯相反,判斷參數(shù) 1 能否賦值給參數(shù) 2,如果不可以則返回 never,以此保留聯(lián)合類型的一部分。
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', age: 18 }, '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>>>>
還有非常多高級類型,實(shí)現(xiàn)思路和上面介紹的類型套路大多一致,這里不一一贅述。
2. 解析 ParseQueryString 復(fù)雜類型
重點(diǎn)解析的是在背景章節(jié)介紹類型體操復(fù)雜度,舉例說明的解析字符串參數(shù)的函數(shù)類型。
如圖示 demo 所示,這個(gè)函數(shù)是用于將指定字符串格式解析為對象格式。
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<string, any> {
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
}
參數(shù)類型為 string,返回類型為 Record<string, any>,這時(shí)看到,res1.a 類型為 any,那么有沒有辦法,準(zhǔn)確的知道 a 的類型是字面量類型 1 呢?
下面就通過類型體操的方式,來重寫解析字符串參數(shù)的函數(shù)類型。
首先限制參數(shù)類型是 string 類型,然后為參數(shù)匹配公式 a&b,如果滿足公式,將 a 解析為 key value 的映射類型,將 b 遞歸 ParseQueryString 類型,繼續(xù)解析,直到不再滿足 a&b 公式。
最后,就可以得到一個(gè)精準(zhǔn)的函數(shù)返回類型,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
四、小結(jié)—
綜上分享,從 3 個(gè)方面介紹了類型體操。
第一點(diǎn)是類型體操背景,了解了什么是類型,什么是類型安全,怎么實(shí)現(xiàn)類型安全;
第二點(diǎn)是熟悉類型體操的主要類型、支持的邏輯運(yùn)算,并總結(jié)了 4 個(gè)類型套路;
第三點(diǎn)是類型體操實(shí)踐,解析了 TypeScript 內(nèi)置高級類型的實(shí)現(xiàn),并手寫了一些復(fù)雜函數(shù)類型。
從中我們了解到需要?jiǎng)討B(tài)生成類型的場景,必然是要用類型編程做一些運(yùn)算,即使有的場景下可以不用類型編程,但是使用類型編程能夠有更精準(zhǔn)的類型提示和檢查,減少代碼中潛在的問題。
參考資料+源碼—
這里列舉了本次分享的參考資料及示例源碼,歡迎大家擴(kuò)展閱讀:
參考資料《TypeScript 類型體操通關(guān)秘籍》: https://juejin.cn/book/7047524421182947366
[2]示例源碼: https://github.com/jiaozitang/ts-demo
