【TS】1018- TypeScript 4.3 新功能的實(shí)踐應(yīng)用
本文通過解決在實(shí)際工作中遇到的問題,層層剖析解法,帶你了解 TS4.3 的高級特性,一起來看看吧。
已經(jīng)成為前端標(biāo)配的 TypeScript 在 5 月底發(fā)布 4.3 版本。作為一個(gè)小版本迭代,粗看并沒有什么令人驚艷的新功能。但如果你真的有在持續(xù)關(guān)注 TypeScript,那么其中的一項(xiàng)更新值得重點(diǎn)關(guān)注:
Template String Type Improvements
為什么值得注意呢?看一下 TS 4.0 以來的三條更新記錄:
4.0 版本新增 Variadic Tuple Types
4.1 版本新增 Template Literal Types
4.3 版本完善 Template Literal Types
然后我現(xiàn)在告訴你,Tuple Types 和 Template Literal Types 其實(shí)是一對關(guān)系密切的好哥們。所以,聰明的你是不是已經(jīng)猜到,既然 TS 在 Tuple Types 和 Template Literal Types 持續(xù)發(fā)力,那很大概率,現(xiàn)在應(yīng)該可以用它們來完成一些以前不太可能完成的事情。
而我呢,早在 4 月份的時(shí)候就發(fā)現(xiàn)了 TS 4.3 將要發(fā)布的這個(gè)新功能,并且已經(jīng)在預(yù)覽版中親身體驗(yàn),解決了一個(gè)非常有趣的小問題:如何將對象類型的所有可能的合法路徑靜態(tài)類型化。
下面就讓我?guī)憧纯?4.3 增強(qiáng)之后的 Template Literal Types 可以解決一個(gè)什么樣的真實(shí)問題吧。
還原問題現(xiàn)場
我們團(tuán)隊(duì)現(xiàn)在的項(xiàng)目中使用 FinalForm 管理表單狀態(tài),但這不是重點(diǎn),重點(diǎn)是其中一個(gè)和 lodash set 方法幾乎一模一樣的 change 方法,做不到完全的類型安全。這導(dǎo)致我們在寫相關(guān)的 TS 代碼時(shí),只能用稍顯丑陋的 as any 逃生。具體示例看 ?? 的代碼:
type NestedForm = {
name: ['趙' | '錢' | '孫' | '李', string];
age: number;
articles: {
title: string;
sections: string[];
date: number;
likes: {
name: [string, string];
age: number;
}[];
}[];
}
// FinalForm 中的一個(gè)常用 API,語義和 lodash 中的 set 幾乎一樣
interface FormApi<FormValues = Record<string, any>> {
change: <F extends keyof FormValues>(name: F, value?: Partial<FormValues[F]>) => void
}
const form: FormApi<NestedForm> = // 假裝有了一個(gè) form 實(shí)例
// 基本使用
form.change('age', '20') // 這樣是類型安全的
// 可大量的真實(shí)使用場景其實(shí)類型不安全,但又完全合情合理,所以只能使用 as any 逃生
form.change('name.0', '劉')
form.change('articles.0.title', 'some string')
form.change('articles.0.sections.2', 'some string')
// 項(xiàng)目中逃生代碼
<Select
placeholder="請選擇類型"
onChange={Kind => {
// 清空其他字段, 只保留 Kind
form.change(`${field}.Env.${i}` as any, { Kind });
}}
>
所以問題就是:我們能讓類似的方法完全的類型安全嗎?
答案我也不藏著掖著了:解決此類問題需要 4.3 增強(qiáng)之后的 Template Literal Types 和 4.0 版本新增 Variadic Tuple Types,再加上一些其它早就有的高級特性。
看到這些新增和高級字眼,妥妥的一道高階 TS 面試題 ?? 有木有。而我確實(shí)也能向你保證,如果接下來的內(nèi)容,你能做到既知其然,又知其所以然,TS 這關(guān)你穩(wěn)過。
解決方案拆解,由淺入深
第一步:核心技術(shù)支撐
很多時(shí)候,解決方案往往已經(jīng)藏在問題中
name.0name.1articles.0.likes.0.agenameagearticleschange 方法類型安全的部分是對象最外層的 key:
類型不安全的部分是對象其它的嵌套路徑:
我們的目標(biāo)其實(shí)很清晰了:得到對象的全部可能路徑。也許這依然有些模糊,但如果如果我換個(gè)說法,你或許就明白了:給你一顆二叉樹,問題是從根節(jié)點(diǎn)出發(fā),所有可能的路徑。
但是這些和 Template Literal Types 有什么關(guān)系嗎?!當(dāng)然有,而且非常有。我們都知道 articles.0.likes.0.age 是字符串,但是它更是 template string type。也正是它,可以讓我們在類型層面表示出一個(gè)對象的全部嵌套子路徑。
第二步:Template Literal Types 搭配 Variadic Tuple Types 顯奇效
這一步不要求你能全部看懂,先有個(gè)大致的概念和感覺,先讓你知道,Template Literal Types 搭配 Variadic Tuple Types,再用上一些泛型技巧,可以穩(wěn)穩(wěn)的拿到對象的全部嵌套子路徑。后面會詳細(xì)介紹如何用泛型求解對象的全部嵌套子路徑。
核心操作
['articles', number] => articles.${number}join
split
articles.${number}=> '['articles', number]詳細(xì)操作
{ name: { firstName: string, secondName: string }, hobby: string[] }
每一個(gè)路徑都是一個(gè) tuple,所有路徑就是所有 tuple 的聯(lián)合 ??
['name'] | [hobby] | ['name', 'firstName'] | ['name', 'secondName'] | ['hobby', number]
tuple 可以輕松轉(zhuǎn)為 template string type ??
name|hobby|name.firstName|name. secondName|hobby.${number}然后就是如何根據(jù) path 得到 path 對應(yīng)的 value 的類型 ??
給定 name.firstName可以知道對應(yīng)的 value 類型是 string給定 hobby.${number}可以知道對應(yīng)的 value 類型是 string結(jié)論:template string type 與 tuple type 可以等價(jià)轉(zhuǎn)換
第三步:你可能不了解的 TS 高級特性
在具體詳解泛型函數(shù)之前,本節(jié)想要先介紹一些你可能不了解 TS 高級特性,如果你非常有自信,可以略過此節(jié),直接去看后面的泛型函數(shù),如果發(fā)現(xiàn)看不懂,回頭再看此節(jié)也不遲。
1. 你可能不了解的 TS 類型系統(tǒng)
我們知道 TS 最核心的功能就是一套靜態(tài)類型系統(tǒng),但你真的懂 TS 類型系統(tǒng)嗎?讓我問你一個(gè)問題測試一下:TS 的類型是值的集合嗎?
這是一個(gè)非常有趣的問題,正確答案是:編程語言中的類型,除了一個(gè)特例之外,確實(shí)都是值的集合。但因?yàn)樘乩拇嬖冢覀兙筒荒軐⒕幊陶Z言中的類型視為值的集合。這個(gè)特例在 TS 中叫 never,并無對應(yīng)的值,用于表示代碼會崩潰退出或陷入死循環(huán)。并且,never 是所有類型的子類型,這意味著你寫的任何看似被靜態(tài)類型保護(hù)著的安全無憂的函數(shù),實(shí)際運(yùn)行時(shí)也都有可能崩潰或死循環(huán)。很無奈,這種沒人喜歡的可能性是靜態(tài)類型系統(tǒng)允許的合法行為。所以,靜態(tài)類型也不是萬能的。
2. Conditional types
At the heart of most useful programs, we have to make decisions based on input.
Conditional types help describe the relation between the types of inputs and outputs.
條件類型的引入,是 TS 泛型開始發(fā)光發(fā)熱的基礎(chǔ)。我們都知道,編程不可能離開用條件分支做決定,任何實(shí)際編程項(xiàng)目中,都隨處可見 if else。
TS 泛型中最普通的條件分支是這個(gè)樣子的:
SomeType extends OtherType ? TrueType : FalseType;
我們可以基于條件分支做一些有用事情。比如判斷一個(gè)類型是不是數(shù)組類型,如果是,就返回?cái)?shù)組的元素類型。
type Flatten<T> = T extends unknown[] ? T[number] : T;
// Extracts out the element type.
type Str = Flatten<string[]>;
// string
// Leaves the type alone.
type Num = Flatten<number>;
// number
Distributive Conditional Types
When conditional types act on a generic type, they become distributive when given a union type.
編程除了用分支做決定外,還離不開循環(huán),畢竟一個(gè)個(gè)手寫是完全不現(xiàn)實(shí)的,TS 泛型函數(shù)并沒有常規(guī)意義上的 for 或 while 循環(huán),但卻有 Distributive Conditional Types,其作用非常類似數(shù)組的 map 方法,只不過是作用對象是 union 類型而已。具體表現(xiàn)可以直接看下面的圖示:

3. Inferring Within Conditional Types
關(guān)于條件類型還有一個(gè)不可缺失的高階特性:infer 推斷。TS 的 infer 能力可以讓我們使用聲明式的編程方法從一個(gè)復(fù)雜復(fù)合類型中精準(zhǔn)提取出我們感興趣的那部分。
Here, we used the
inferkeyword to declaratively introduce a new generic type variable namedIteminstead of specifying how to retrieve the element type ofTwithin the true branch.
例如上面提取數(shù)組元素類型的泛型可以用 infer 實(shí)現(xiàn)如下,看上去是不是更簡潔省勁一些呢?
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
4. 元組 tuple 和模版字符串類型 template string type 的遞歸操作
這一小節(jié)之前的內(nèi)容都只算熱身,這一小節(jié)的遞歸泛型是本文核心。解決方案拆解的第一步已經(jīng)指出核心技術(shù)支撐是 Variadic Tuple Types 和 Template Literal Types。這一小節(jié)將在條件泛型和 infer 的基礎(chǔ)上引入 tuple 和 template string 的遞歸操作。
Tuple 就是 length 固定,每一個(gè)元素類型也固定的 Array,如下面代碼所示,Test1 是一個(gè) tuple,length 固定為 4,每一個(gè)元素類型也固定。JoinTupleToTemplateStringType 是一個(gè)泛型函數(shù),可以將一個(gè) Tuple 轉(zhuǎn)換為 Template Literal Types,作用到 Test1 上得到的結(jié)果是 names.${number}.firstName.lastName。具體到 JoinTupleToTemplateStringType 的實(shí)現(xiàn),除了條件類型和 infer 的使用,我們還使用了一個(gè)威力巨大的 TS 泛型特性:遞歸。如果對算法略有了解,會知道任何算法操作的核心是分支和循環(huán),而循環(huán)又何遞歸完全等價(jià),意思是任何用循環(huán)實(shí)現(xiàn)的算法,理論上都可以用遞歸實(shí)現(xiàn),反之亦然。在目前主流編程語言中,絕大部分都是以循環(huán)為主,甚至很多人可能聽過一些「不要寫遞歸」之類的說法。但在 TS 泛型層面,我們只能使用遞歸和條件來實(shí)現(xiàn)一些有趣的泛型函數(shù)。下面的代碼我加了詳細(xì)的注釋,順著慢慢看,別害怕,就一定能看懂。因?yàn)檫f歸有一個(gè)特點(diǎn),寫起來可能不容易,但閱讀的時(shí)候往往要容易很多(前提是單個(gè)邏輯完整且不存在嵌套的遞歸)。
type Test1 = ['names', number, 'firstName', 'lastName'];
// 假設(shè)需要處理的 Tuple 元素類型只會是字符串或 number
// 做這個(gè)假設(shè)的原因是,對象 object 的 key 一般來說,只會是 string 或 number
type JoinTupleToTemplateStringType<T> = T extends [infer Single] // 此處是遞歸基,用于判斷 T 是否已經(jīng)是最簡單的只有一個(gè)元素的 Tuple
? Single extends string | number // 如果是遞歸基,則提取出 Single 的具體類型
? `${Single}`
: never
// 如果還未到遞歸基,則繼續(xù)遞歸
: T extends [infer First, ...infer RestTuple] // 完全類似 JS 數(shù)組解構(gòu)
? First extends string | number
? `${First}.${JoinTupleToTemplateStringType<RestTuple>}` // 遞歸操作
: never
: never;
type TestJoinTupleToTemplateStringType = JoinTupleToTemplateStringType<Test1>;
在上面的遞歸操作里,是把 Tuple 轉(zhuǎn)換成 Template Literal Type,下面這個(gè)遞歸泛型相反,是把一個(gè) Template Literal Type 轉(zhuǎn)換成 Tuple。代碼也加了詳細(xì)注釋,別害怕,只要慢慢看,就一定能看懂。
type Test2 = `names.${number}.firstName.lastName.${number}`;
type SplitTemplateStringTypeToTuple<T> =
T extends `${infer First}.${infer Rest}`
// 此分支表示需要繼續(xù)遞歸
? First extends `${number}`
? [number, ...SplitTemplateStringTypeToTuple<Rest>] // 完全類似 JS 數(shù)組構(gòu)造
: [First, ...SplitTemplateStringTypeToTuple<Rest>]
// 此分支表示抵達(dá)遞歸基,遞歸基不是 nubmer 就是 string
: T extends `${number}`
? [number]
: [T];
type TestSplitTemplateStringTypeToTuple = SplitTemplateStringTypeToTuple<Test2>;
最后一步:求解對象全部嵌套子路徑的遞歸泛型
終于到了最后一步,真正的解決方案,一個(gè)求解對象全部嵌套子路徑的遞歸泛型 AllPathsOf。AllPathsOf 并不復(fù)雜,由兩個(gè)嵌套泛型構(gòu)成,這兩個(gè)嵌套泛型分別只有七八行,加起來十五行,是不是還行?所以問題最關(guān)鍵的一步是想到先求出 TuplePaths,再鋪平。其中鋪平這一步我們之前已經(jīng)展示過,就是用一個(gè)遞歸泛型把一個(gè) Tuple 轉(zhuǎn)換成 Template Literal Type。所以問題只剩下一個(gè):如何把對象的所有子路徑提取并表示為 Tuple Union。RecursivelyTuplePaths 本身也不復(fù)雜,下面代碼中有詳細(xì)注釋,別害怕,慢慢看,一定能看懂。
剩下就是的 ValueMatchingPath,看代碼好像比 AllPathsOf 還復(fù)雜一點(diǎn),但由于只是附加功能,此處不詳細(xì)介紹,感興趣的可以看代碼,相信經(jīng)過前面幾輪遞歸泛型的洗禮,這個(gè)稍微長一點(diǎn)的也不成問題。
//
// 支持的環(huán)境:TS 4.3+
//
/** 獲取嵌套對象的全部子路徑 */
type AllPathsOf<NestedObj> = object extends NestedObj
? never
// 先把全部子路徑組織成 tuple union,再把每一個(gè) tuple 展平為 Template Literal Type
: FlattenPathTuples<RecursivelyTuplePaths<NestedObj>>;
/** 給定子路徑和嵌套對象,獲取子路徑對應(yīng)的 value 類型 */
export type ValueMatchingPath<NestedObj, Path extends AllPathsOf<NestedObj>> =
string extends Path
? any
: object extends NestedObj
? any
: NestedObj extends readonly (infer SingleValue)[] // Array 情況
? Path extends `${string}.${infer NextPath}`
? NextPath extends AllPathsOf<NestedObj[number]> // Path 有嵌套情況,繼續(xù)遞歸
? ValueMatchingPath<NestedObj[number], NextPath>
: never
: SingleValue // Path 無嵌套情況,數(shù)組的 item 類型就是目標(biāo)結(jié)果
: Path extends keyof NestedObj // Record 情況
? NestedObj[Path] // Path 是 Record 的 key 之一,則可直接返回目標(biāo)結(jié)果
: Path extends `${infer Key}.${infer NextPath}` // 否則繼續(xù)遞歸
? Key extends keyof NestedObj
? NextPath extends AllPathsOf<NestedObj[Key]> // 通過兩層判斷進(jìn)入遞歸
? ValueMatchingPath<NestedObj[Key], NextPath>
: never
: never
: never;
/**
* Recursively convert objects to tuples, like
* `{ name: { first: string } }` -> `['name'] | ['name', 'first']`
*/
type RecursivelyTuplePaths<NestedObj> = NestedObj extends (infer ItemValue)[] // Array 情況
// Array 情況需要返回一個(gè) number,然后繼續(xù)遞歸
? [number] | [number, ...RecursivelyTuplePaths<ItemValue>] // 完全類似 JS 數(shù)組構(gòu)造方法
: NestedObj extends Record<string, any> // Record 情況
?
// record 情況需要返回 record 最外層的 key,然后繼續(xù)遞歸
| [keyof NestedObj]
| {
[Key in keyof NestedObj]: [Key, ...RecursivelyTuplePaths<NestedObj[Key]>];
}[Extract<keyof NestedObj, string>]
// 此處稍微有些復(fù)雜,但做的事其實(shí)就是構(gòu)造一個(gè)對象,value 是我們想要的 tuple
// 最后再將 value 提取出來
// 既不是數(shù)組又不是 record 時(shí),表示遇到了基本類型,遞歸結(jié)束,返回空 tuple。
: [];
/**
* Flatten tuples created by RecursivelyTupleKeys into a union of paths, like:
* `['name'] | ['name', 'first' ] -> 'name' | 'name.first'`
*/
type FlattenPathTuples<PathTuple extends unknown[]> = PathTuple extends []
? never
: PathTuple extends [infer SinglePath] // 注意,[string] 是 Tuple
? SinglePath extends string | number // 通過條件判斷提取 Path 類型
? `${SinglePath}`
: never
: PathTuple extends [infer PrefixPath, ...infer RestTuple] // 是不是和數(shù)組解構(gòu)的語法很像?
? PrefixPath extends string | number // 通過條件判斷繼續(xù)遞歸
? `${PrefixPath}.${FlattenPathTuples<Extract<RestTuple, (string | number)[]>>}`
: never
: string;
/**
* 借助 TS 4.3 的新能力(template string type 增強(qiáng))改造 FormApi interface 中的 change 方法,可用性幾乎完美
* */
interface FormApi<FormValues = Record<string, any>> {
change: <Path extends AllPathsOf<FormValues>>(
name: Path,
value?: Partial<ValueMatchingPath<FormValues, Path>>
) => void;
}
// 演示用的嵌套 Form 類型
interface NestedForm {
name: ['趙' | '錢' | '孫' | '李', string];
age: number;
articles: {
title: string;
sections: string[];
date: number;
likes: {
name: [string, string];
age: number;
}[];
}[];
}
// 假裝有了一個(gè) NestedForm 類型表單實(shí)例的 change 方法
const change: FormApi<NestedForm>['change'] = (name, value) => {
console.log(name, value);
};
// ?? 盡情嘗試
let index = 0;
change(`articles.0.likes.${index}.age`, 10);
change(`name.${index}`, '劉'); // 其實(shí)此處依然不夠安全,可以想想怎么更安全 ??
/** 提取出來的全部子路徑,放在這里直觀展示一下 */
type AllPathsOfNestedForm =
| keyof NestedForm
| `name.${number}`
| `articles.${number}`
| `articles.${number}.title`
| `articles.${number}.sections`
| `articles.${number}.date`
| `articles.${number}.likes`
| `articles.${number}.sections.${number}`
| `articles.${number}.likes.${number}`
| `articles.${number}.likes.${number}.name.${number}`
| `articles.${number}.likes.${number}.age`
| `articles.${number}.likes.${number}.name`;
最最后一步:使用尾遞歸技術(shù)優(yōu)化泛型函數(shù)的性能
最最后一步是個(gè) bonus,額外的優(yōu)化??梢钥吹角懊娴?AllPathsOf 是個(gè)運(yùn)行復(fù)雜度不低的遞歸。這應(yīng)該是遞歸的通病,也有一些朋友因?yàn)檫@個(gè)不喜歡遞歸。但其實(shí)遞歸的這種問題是可以通過技術(shù)手段規(guī)避掉的。這個(gè)技術(shù)手段就是尾遞歸。
下面我們用經(jīng)典的 fibonacci 數(shù)列來切實(shí)感受一下遞歸、尾遞歸、循環(huán)的區(qū)別:
// 遞歸版 fibonacci,性能捉急,簡直不可容忍
function fibRecursive(n: number): number {
return n <= 1 ? n : fibRecursive(n - 1) + fibRecursive(n - 2);
}
// 尾遞歸版 fibonacci,化腐朽為神奇,性能飆升
function fibTailRecursive(n: number) {
function fib(a: number, b: number, n: number): number {
return n === 0 ? a : fib(b, a + b, n - 1);
}
return fib(0, 1, n);
}
// 循環(huán)版 fibonacci,好像和尾遞歸版異曲同工?
function fibLoop(n: number) {
let [a, b] = [0, 1];
for (let i = 0; i < n; i++) {
[a, b] = [b, a + b];
}
return a;
}
是的,尾遞歸的性能在時(shí)間復(fù)雜度上和循環(huán)一樣一樣的。
下面看看尾遞歸如何在 TS 泛型中使用:
type OneLevelPathOf<T> = keyof T & (string | number)
type PathForHint<T> = OneLevelPathOf<T>;
// P 參數(shù)是一個(gè)狀態(tài)容器,用于承載每一步的遞歸結(jié)果,并最終幫我們實(shí)現(xiàn)尾遞歸
type PathOf<T, K extends string, P extends string = ''> =
K extends `${infer U}.${infer V}`
? U extends keyof T // Record
? PathOf<T[U], V, `${P}${U}.`>
: T extends unknown[] // Array
? PathOf<T[number], V, `${P}${number}.`>
: `${P}${PathForHint<T>}` // 走到此分支,表示參數(shù)有誤,提示用戶正確的參數(shù)
: K extends keyof T
? `${P}${K}`
: T extends unknown[]
? `${P}${number}`
: `${P}${PathForHint<T>}`; // 走到此分支,表示參數(shù)有誤,提示用戶正確的參數(shù)
/**
* 使用尾遞歸泛型改造 FormApi interface 中的 change 方法,提升性能
* */
interface FormApi<FormValues = Record<string, any>> {
change: <Path extends string>(
// 此處按需判斷給定的 name 參數(shù)是否是 FormValues 的子路徑
// 編譯性能會有明顯提升
name: PathOf<FormValues, Path>,
value?: Partial<ValueMatchingPath<FormValues, Path>>
) => void;
}
結(jié)語
TS 4.3 Template Literal Types 實(shí)踐到這里就結(jié)束了。這些略有復(fù)雜但邏輯清晰的遞歸泛型理解起來肯定有一些難度,如果實(shí)在看不懂,也沒關(guān)系。后面可以慢慢來。但想要真正掌握 TS,這個(gè)程度的遞歸泛型是必須要掌握的,所以本文的確還是有一些價(jià)值的 ?? ??。
參考鏈接
https://github.com/microsoft/TypeScript/issues/20423

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
