[Day12] TS:什麼!型別還有遞迴(recursion)的概念?用組合技實作 SnakeToCamelCase
這是我們今天要聊的內容,老樣的,如果你已經可以輕鬆看懂,歡迎直接左轉去看我同事 Andy 精彩的文章 — 「前端工程師學習 DevOps 之路」。
什麼是遞迴函式(recursive function)
遞迴(recursion)是寫程式時會使用到的概念,特別是在刷題的時候!?(推薦參考:Day 02 : Fibonacci 斐波那契 @ 30 天用 JavaScript 刷題刷起來!)
遞迴函式(recursive function)簡單來說就是在一個函式中呼叫它自己,舉例來說:
const countDown = (num) => {
console.log(num);
return num > 0 ? countDown(num - 1) : 0;
};
在這個 countDown
函式中會去呼叫它自己(countDown
):
recursive function 一定要有一個終止的條件,以這裡來說,就是當 num <= 0
時,就不會再次呼叫自己。
TypeScript 中也能使用遞迴
回到 TypeScript,在 TypeScript 中的 Type Alias 和 Conditional Type 也一樣可以使用遞迴函式的概念。
Recursive Type Aliases
來比較一下下面這兩個例子:
// 沒有使用 Recursive Type
type ValueOrArray<T> = T | T[];
// 使用了 Recursive Type Aliases
type ValueOrNestedArray<T> = T | ValueOrNestedArray<T>[];
先看上面的 ValueOrArray<T>
這個 Utility Type,它的概念很簡單,它產生的型別可以是原本帶入 <>
內的型別,或者是這個型別所建立的陣列:
type NumberArray = ValueOrArray<number>;
let numberArray: NumberArray = 0;
numberArray = [0, 1];
// ERROR: Type '[number]' is not assignable to type 'number'
numberArray = [0, [1]];
我們可以看到要滿足 ValueOrArray<number>
的話,可以是一般的 number
,或者是 number[]
,但如果是 nested 的 number[]
,TS 就會報錯:
接著我們看到第二個例子 ValueOrNestedArray<T>
, 你會發現到 T | ValueOrNestedArray<T>[]
指的是它除了可以是原本帶入 <>
的型別外,還在這個 Type 裡呼叫了它自己,這麼做的概念就很像是:
type ValueOrNestedArray<T> = T | T[] | T[][] | T[][][] | ...;
因此,如果要滿足 ValueOrNestedArray<number>
,只要是 number array 都可以,即使它是 nested 的 number array:
type NestedNumberArray = ValueOrNestedArray<number>;
let nestedNumberArray: NestedNumberArray = 0;
nestedNumberArray = [0, 1];
nestedNumberArray = [0, [1]];
nestedNumberArray = [0, [1, [2]]];
// ERROR: Type 'string' is not assignable to type 'ValueOrNestedArray<number>'
nestedNumberArray = ['0', [1]];
除非帶入陣列的值不是 number
,否則都是能夠滿足 ValueOrNestedArray<number>
的,而這就是遞迴在 TypeScript 中的使用 — 在一個 Utility Type 中呼叫自己。
遞迴概念也可以使用在 Conditional Types 中,讓我們回到今天最開始的那個範例。
SnakeToCamelCase 的使用方式
讓我們來看一下今天的主角 SnakeToCamelCase
,它的作用會像這樣:
type T1 = SnakeToCamelCase<'this_is_snake_case'>; // "thisIsSnakeCase"
type T2 = SnakeToCamelCase<'This_Is_Strange_Case'>; // "thisIsStrangeCase"
type T3 = SnakeToCamelCase<'IDontKnowThis'>; // "IDontKnowThis"
- 如果原本傳入的字串型別符合 snake case 的話,它可以把原本是
snake_case
的字串型別轉換成CamelCase
。 - 如果原本傳入的字串型別不符合 snake case 的話,則會直接回傳原本的型別回去。
SnakeToCamelCase
是修改自 ts-case-convert 中的ToCamel
這個 Utility Type。
使用組合技寫出 SnakeToCamelCase
要理解這段原始碼,我們需要用到的知識包含前幾天提到的:
- Generics @ Day02
- Generic Constraints @ Day03
- Conditional Types @ Day08
- infer @ Day10
- Template Literal Types @ Day 11
- Recursion @ 今天
同時,隨著對 TypeScript 的知識越來越豐富,未來將會看到更複雜的 Utility Types,但原則是類似的,要先能夠做出正確的斷句,因此讓我們先把它拆開來一一理解。
理解原始碼:Generic Constraints & Conditional Types
- Generics Constraints:也就是
<T extends string>
,從這裡可以知道帶入 SnakeToCamelCase 的型別一定要是字串型別 - Conditional Types:在講使用方式時有提過,如果帶入的型別符合 snake case 的話,它會把它轉成 camel case,否則就直接回傳原本的型別回去。「如果...則...否則...」這種語句就表示用了 Conditional Types,也就是這裡的
extends ... ? ... : ...
,至於是怎麼判斷它是不是 snake case 的話,會在下面提到。
理解原始碼:infer
這裡是怎麼判斷使用者帶入的型別是否符合 snake case 呢?可以看到這裡使用的條件判斷是 ${...}_${...}
,也即是說,這個字串型別中有 _
存在,它就可以滿足 snake case。
同時可以看到這裡用的了 infer
,這裡 infer 的作用可以幫助我們「擷取」這個 snake case 的字串,把它拆成頭(Head)和尾(Tail):
可以看到如果傳入的是 this_is_snake_case
的話,它的 Head
會是 this
,Tail
會是 is_snake_case
。
理解原始碼:Uncapitalize 和 Capitalize(Intrinsic String Manipulation Types)
接著把注意力放到當條件為 True 時,裡面用了 Uncapitalize
和 Capitalize
:
這兩個 Utility Types 稱作「Intrinsic String Manipulation Types」,它們的用法就和它們的命名一樣:
Uncapitalize<StringType>
:把第一個字母變小寫Capitalize<StringType>
:把第一個字母變大寫
除了這兩個之外,目前還有 Lowercase<StringType>
(把所有字母變小寫)和 Uppercase<StringType>
(把所有字母變大寫)這兩個 Intrinsic String Manipulation Types。
Intrinsic String Manipulation Types 和其他的 Utility Types 有個不同的地方,Intrinsic String Manipulation Types 為了效能緣故是內建在 TS compiler 中的,因此並沒有辦法和其他 Utility Types 一樣直接從
.d.ts
中看到它們的原始碼。
現在我們知道:
Uncapitalize<Head>
會把 infer 擷取出來的Head
的第一個英文字母轉成小寫,this
因為原本第一個字就是小寫,所以不會有改變Capitalize<Tail>
則會把 infer 擷取出來的Tail
的第一個英文字母轉成大寫,is_snake_case
會變成Is_snake_case
如下圖所示:
理解原始碼:recursion
現在好像可以看出一點所以然,知道是怎麼判斷傳進來的型別是不是符合 snake case,也知道怎麼把它切成頭尾後轉成 Uncapitalize 和 Capitalize,但還少了最後一步,就是今天學到的 recursion:
這裡我們在 SnakeToCamelCase
這個 Utility Types 中,又去呼叫了自己。
在學習 TypeScript 的型別操作時,有一個很好的方式是:「遇到不懂或不確定的情況,一種是帶一個實際的值進去,另一種是『把它移掉,然後看看會發生什麼事』」,而要了解 recursion 幫我們做了什麼,就可以把它移掉試試看:
// 為了理解 recursion 的作用,把原本在 Capitalize<> 內的 recursive function 拿掉
type SnakeToCamelCaseWithoutRecursion<T extends string> =
T extends `${infer Head}_${infer Tail}`
? `${Uncapitalize<Head>}${Capitalize<Tail>}`
: T;
如果帶入原本的範例會發現,它們都只被改了一半,也就是只有 thisIs_
的部分:
type T1 = SnakeToCamelCaseWithoutRecursion<'this_is_snake_case'>; // "thisIs_snake_case"
type T2 = SnakeToCamelCaseWithoutRecursion<'This_Is_Strange_Case'>; // "thisIs_Strange_Case"
這時候就可以猜到,這裡的 recursion ${Capitalize<SnakeToCamelCase<Tail>>}
做的事情,就是把前一次推論得到的 Tail
再當成參數帶入 SnakeToCamelCase<Tail>
中,而這個 recursion 會一直重複執行,直到最後帶入的 Tail 不符合 snake case 時終止:
而這也就是為什麼透過 SnakeToCamelCase
這個 Utility Type 能夠把 snake case 的字串型別轉換成 camelCase 了。
範例:FixPathSquareBrackets
在 type-fest 這個套件中,提供了一個名為 FixPathSquareBrackets 的 Utility Type,作用是把原本使用 square-bracketed syntax []
的語法,改成使用 dot notation,具體來說就是:
`foo[0].bar` -> `foo.0.bar`
這個 Type Utility 的原始碼是這樣:
type FixPathSquareBrackets<Path extends string> =
Path extends `${infer Head}[${infer Middle}]${infer Tail}`
? `${Head}.${Middle}${FixPathSquareBrackets<Tail>}`
: Path;
讀者們如果能夠理解今天的內容,就可以試著理解 FixPathSquareBrackets 這段原始碼是什麼意思!
Recursion 小技巧
- 雖然在 TypeScript 中可以使用遞迴來達到強大的功能,但需要謹慎使用,因為它會讓 Type Checking 所消耗的效能和時間增加,所以雖然可以用 TS 寫 Fibonacci 的 Utility Type,但請不要這麼做!
- 在學習 TypeScript 的型別操作時,有一個很好的方式是:「遇到不懂或不確定的情況,一種是帶一個實際的值進去,另一種是『把它移掉,然後看看會發生什麼事』」,而要了解 recursion 幫我們做了什麼,就可以把它移掉試試看。
範例程式碼
https://tsplay.dev/N5205m @ TypeScript Playground
參考
- ToCamel @ ts-case-convert
- Recursive Type Aliases @ TypeScript > TypeScript 3.7
- Recursive Conditional Types @ TypeScript > TypeScript 4.1
- Intrinsic String Manipulation Types @ TypeScript > Type Manipulation