釋放巢狀物件的型別標示吧! feat. TypeScript: Conditional Types

Ken
OneDegree Tech Blog
11 min readAug 18, 2021

在開發程式某些情況下,我們偶爾必須使用一些比較深層、巢狀的物件。假如,我們想要嚴謹的定義 type 或 interface 來保護我們的程式,而過於嵌套的 Type 往往難以一眼辨識出最終的形狀。

舉例假設我們今天有個物件是 book、book 底下有個物件 author、author 底下有個物件 address… 形狀如下:

const book = {
name: "子彈思考整理術",
author: {
firstName: "瑞德",
lastName: "卡洛",
address: {
postCode: 102,
street: "Carl St.",
city: "New York",
country: "USA",
},
},
};

我們可以實作 3 個 type 或 interface 來組出 Book,但型別提示…,會像是下 圖左 一樣,無法一眼看到 type Book 的最終樣貌。今天要來介紹的就是怎麼透過 Conditional Types 達成下 圖右 的結果,展開你深層物件的型別!

圖2 — Simplify Type
一般提示:圖左(行動版圖上) vs 完整提示:圖右(行動版圖下)

那廢話不多說,首先就來介紹什麼是 Conditional Types

什麼是 Conditional Types?

解釋完畢。喂~別鬧!

不曉得各位看倌是否覺得這段神秘的語法似曾相識?乍看之下有點類似 JavaScript 中的 3元運算子 (Ternary Operator)?

const result = score >= 60 ? '及格' : '不及格' ;

這個 operator 要做的事情是…
假如 [ ? ] 前的結果是 truthy (符合)的話, [ ? ][ : ] 間的 expression 會被執行;
假如 [ ? ] 前的結果是 falsy (不符合)的話, [ : ] 之後的 expression 會被執行。

以上方例子而言,當 score 小於 60 時,會回傳 ‘不及格’ 存入 result 中;當 score 大於等於 60 時,會回傳 ‘及格’ 存入 result 中。

JS 裡的 3元運算子 經常被用於取代繁冗的 if statement。

那在 TypeScript 的 Conditional Types 是什麼狀況呢??

TL;DR 見下圖範例

null 可以被指定給 null | undefined 所以 type Foo 是 string type
boolean 不能被指給 null | undefined 所以 type Bar 是 number type

T extends U ? X : Y 的前半段 T extends U 指的是 T 能否被指定給 U 的型別。比如說 1,2,3 ( literal types )能被指定給 number 的型別;null 能被指定給 null | undefined 聯集的型別。

但是反言之,number 不能指定給 1,2,3 (這些 literal types ); null | undefined 聯集也不能指定給 null type!

T extends U ? X : Y 是什麼呢?當 T extends U 成立時,型別是 X ;不成立則型別是 Y。(迷路的話,我們可以再回到上方 Foo/Bar 範例看看)

TypeScript 不就只是幫 JavaScript 增添型別定義及檢查而已嘛?為什麼還需要這種奇怪的運算來推斷型別???

試想在開發、抽象化程式碼的過程中,我們可能有個 type Foo 在某些條件下希望會是 A 型別,反之則是 B 型別。

舉個更實用的例子 NonNullable:

透過 NonNullable 我們可以重複使用 UserEmailInput 這個 type,創造出一個新的 type。

試想我們要如何開發一個 utility types NonNullable 來幫我們限制型別不可為空?

/**
* 把 T 裡面的 null 跟 undefined 排除
*/
type NonNullable<T> = T extends null | undefined ? never : T;

究竟 NonNullable<UserEmailInput> 是怎麼被推導出來的呢?

TL;DR 見下方範例示意說明程式碼

NonNullable 會依序收到 UserEmailInput 中的 3 個 Type ,但唯一只有 string 不能被指定給 null | undefined ,所以 NonNullable<UserEmailInput> 就會只剩下 string type。

如此,我們也可以略窺見 Conditional Types 對於開發的便利及存在的價值。

註解中關於 Subtype 細節可見連結

怎麼實作一個 utility type Simplify<T>來解開我們的深層、巢狀物件 ?

前置條件如下,但看了可能會黑人問號,讓我娓娓道來

  1. Indexed Access Types
  2. Keyof Type Operator
  3. Mapped Types
  4. Conditional Types
  5. 遞迴可套用在 Conditional Types
  6. 把上面這些東西組合在一起

因為我們是要解開一個巢狀物件,首先我們一定要對操作一個 Object 的 Types 有所掌握。

1. Indexed Access Types

假設我們想透過 Reuse 最一開始 type Address 來建立一個 type City 我們可以怎麼操作? TypeScript 提供了一個很方便的 Indexed Access Types 讓我們只要餵一個 key 給 Address ,我們就可以建立出 type City 了!

那你說假如今天不是 Object type 是一個 Array type 我該怎麼辦?同理,我們可以說 Array 的 key 就是 0,1,2,3 這些 number,我們一樣可以藉由 number 取出 Array 中 element 的 type。

2. Keyof Type Operator

一個 Object 的 key 有那麼多,我們可以不用那麼多 hard code 自己寫 key ,讓 TypeScript 幫你把所有的 key 拿出來嗎?這就是 keyof 的存在意義了!

3. Mapped Types

現在我們拿到 AddressKeys 了(也就是 Address 中所有的 key ),類似 JavaScript 當你想對 AddressKeys 裡面的每個 key 做一些操作、mapping 出它們的 types 時,像使用 for of, for each,你便會需要 Mapped Types,關於 Mapped Types 介紹的文章可以參考:認識 TypeScript 中的型別魔術師:Mapped Type

4. Conditional Types

前面介紹過了

5. 遞迴可套用在 Conditional Types

前面介紹 JavaScript 的範例裡,我們是提供兩個字串:及格、不及格,但其實我們不只可以放入 string, number 這些 value,我們也可以放入任何 expression

const result = score >= 60 ? '及格' : '不及格' ;

Expression 可以像是 加減乘除、字串運算、函式執行等。

因此我們鼎鼎大名的遞迴題目,費伯納西數列,也可以用3元運算解決

const fib = (n) => n >= 2 ? fib(n-1) + fib(n-2) : 1;// 等於function fibonacci (num) {
if (num === 1 || num === 2) {
return 1;
}
return fibonacci(num - 1) + fibonacci(num - 2);
}

同理 Conditional Types 也可以遞迴呼叫!

6. 把上面這些東西組合在一起

先不一次看哪麼複雜

有個 Example type 會等待一個 T 後,決定該產出什麼 Type !

首先我們看第一段 T extends Record<string, any> 這裡表達的是,我們拿到的 T 是不是能被指定給 Object type(即是不是一個物件)?

是的話,取出 Object 中所有的 keys,回傳給新的 Type
否的話,返回該 Type,回傳給新的 Type(即 number, string, boolean 等這些 type)

如此我們已經可以拿到 一個 Object type 所有的 key 或是 非 Object type 自己本身的 type,其實我們已經相當接近完成功能了!

試想我們要解開一個巢狀、深層物件時,是不是要拿到所有的 key 再解開它們背後的 Type,如果不是拿到 Object Type 就會回傳該 Type ( boolean, number, etc… ),拿到 Object Type 就要繼續向下解開。

所以我們應該還差迭代( Mapped Types )跟遞迴!

拿到 Object Type 所有 keys,把每個 key 背後的 Type 都執行類似 Example 的 Simplify,直到拿到最原始的 Type。

type Simplify<T> = T extends Record<string, any> ? { [K in keyof T]: Simplify<T[K]> } : T;

這是什麼意思呢?

首先我們看第一段 T extends Record<string, any> ,如同上個範例,這裡表達的是,我們拿到的 T 是不是能被指定給 Object Type(即是不是一個物件)?

先看後段,T 若不是 Object Type 的話,就回傳 T!(即 number, string, boolean 等這些 type)

再看前段,T 若是 Object Type 的話,{ [K in keyof T]: Simplify<T[K]> }

前段前半:

[K in keyof T] ,代表透過 Mapped Types 拿出原本 Object 中所有的 key ,K 是指要產生的新 Type 中的每一個 key ,那每個 key 後面的 Type 是什麼呢?接著看前段後半

前段後半:

每個 key 後面的 Type 就會是 Simplify<T[K]>,T 是什麼?就是原本最開始拿到的 Object Types;K 是什麼?就是前半段這個 Object 這層的某個 key;T[K],就像是 type City = Address[‘city’];將 Object 某個 key(就是那個 K )取出自己的 Type 就是 T[K]。

接著再把 T[K] 丟進 Simplify<>,遞迴執行,直到 Simplify 不是 Object 後,拿出最終的型別。

如此我們便可以解開一個巢狀、深層物件的型別!

圖2 — Simplify Type
圖左(or 行動版圖上):一般提示 ; 圖右(or 行動版圖下):完整提示

本篇的分享的內容節自公司內部的 Frontend Sharing 的部分,也非常感謝我的同事 Peter 的分享!

小結

  1. Simplify 可以展開我們巢狀的物件型別,使我們在開發時能更方便查看
  2. Conditional Types 可以遞迴呼叫
  3. 透過一系列的組合,我們可以創造出 Simplify 這個 utility Type

參考資料

認識 TypeScript 中的型別魔術師:Mapped Type
Conditional Types in TypeScript
TypeScript 官網

如果這篇文章對你有幫助,請幫我拍手一下
你的拍手及分享是對我最大鼓勵與書寫下篇文章的動力

1下 閱。
5–10下 你認為這篇文章還不錯。
11–20下 你認為這篇貼文對你很有幫助。
21–40下 你非常喜歡這篇貼文,覺得實用。
40–50下 希望我可以多分享關於 TypeScript 相關的文章

--

--