跳至主要内容

[Day12] TS:什麼!型別還有遞迴(recursion)的概念?用組合技實作 SnakeToCamelCase

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):

recursion

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 就會報錯:

Recursive Type Aliases

接著我們看到第二個例子 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

要理解這段原始碼,我們需要用到的知識包含前幾天提到的:

同時,隨著對 TypeScript 的知識越來越豐富,未來將會看到更複雜的 Utility Types,但原則是類似的,要先能夠做出正確的斷句,因此讓我們先把它拆開來一一理解。

理解原始碼:Generic Constraints & Conditional Types

SnakeToCamelCase

  • Generics Constraints:也就是 <T extends string>,從這裡可以知道帶入 SnakeToCamelCase 的型別一定要是字串型別
  • Conditional Types:在講使用方式時有提過,如果帶入的型別符合 snake case 的話,它會把它轉成 camel case,否則就直接回傳原本的型別回去。「如果...則...否則...」這種語句就表示用了 Conditional Types,也就是這裡的 extends ... ? ... : ...,至於是怎麼判斷它是不是 snake case 的話,會在下面提到。

理解原始碼:infer

infer

這裡是怎麼判斷使用者帶入的型別是否符合 snake case 呢?可以看到這裡使用的條件判斷是 ${...}_${...},也即是說,這個字串型別中有 _ 存在,它就可以滿足 snake case。

同時可以看到這裡用的了 infer,這裡 infer 的作用可以幫助我們「擷取」這個 snake case 的字串,把它拆成頭(Head)和尾(Tail):

infer

可以看到如果傳入的是 this_is_snake_case 的話,它的 Head 會是 thisTail 會是 is_snake_case

理解原始碼:Uncapitalize 和 Capitalize(Intrinsic String Manipulation Types)

接著把注意力放到當條件為 True 時,裡面用了 UncapitalizeCapitalize

Intrinsic String Manipulation Types

這兩個 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

如下圖所示:

Intrinsic String Manipulation Types

理解原始碼:recursion

現在好像可以看出一點所以然,知道是怎麼判斷傳進來的型別是不是符合 snake case,也知道怎麼把它切成頭尾後轉成 Uncapitalize 和 Capitalize,但還少了最後一步,就是今天學到的 recursion:

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 時終止:

recursion

而這也就是為什麼透過 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

參考