iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 3
7
Modern Web

讓 TypeScript 成為你全端開發的 ACE!系列 第 3

Day 03. 前線維護・物件型別 X 完整性理論 - Object Types Basics

https://ithelp.ithome.com.tw/upload/images/20190912/20120614EGmbJwgv3T.png

閱讀本篇文章前,仔細想想看

  1. 你能不能分辨 TypeScript 型別推論和註記的差別在哪裡呢?
  2. 試舉出推論的行為到底是什麼?如何運作的?
  3. Nullable Type 可能會造成的各種烏龍狀況?
  4. 型別推論與註記在原始型別(Primitive Type)的使用時機為何?

如果還沒理解完畢的話,可以先翻看前一篇文章喔!

[2019.09.15 新增] tsconfig.json 設定

這裡筆者必須緊急說明:若讀者試著筆者舉的程式碼範例的話,請讀者記得將裡面的 strictNullCheck 選項改成 true,這一點忘記在文章系列的一開頭提醒讀者,實在是很抱歉!

/* tsconfig.json */
{
  "compilerOptions": {
    /*  ...  */
    "strictNullChecks": true,
    /* ... */
  }
}

因此請讀者注意,目前學習的 TypeScript 型別系統版本多了一個 strictNullCheck 的編譯器屬性設定!至於為何會造成如此狀況,那是因為筆者在專案上習慣將某些 TypeScript 編譯器設定啟動!至於 strictNullCheck 到底為何,將會在型別系統講述告一段落後,開始講述 TypeScript 的編譯器設定檔喔!

[2019.09.18 新增] 程式碼範例

如果想要看到本系列文裡面舉的程式碼範例可以參考 Maxwell-Alexius/Iron-Man-Competition 這個 GitHub Repo 喔~寫作過程當中會不斷更新的!

好的!延續昨天的前線維護篇章所探討的 TypeScript 推論與註記的用途與機制,本日正文開始

物件型別 Object Types

環境沿用

還記得上一篇筆者提到的 -- 物件型別再細分成三類:

  • 基礎物件型別:包含 object,陣列(Array<T>T[]),類別以及類別產出的物件本身(不用想,就是 Class 以及 Class Instance)
  • TypeScript 擴充型別:即 Enum 與 Tuple,都是由 TypeScript 介紹出來的
  • 函式型別 Function Types:類似於 (input) => (Ouput) 這種格式的型別,後面會再多做說明

光是這樣可能會讓讀者嚇死(不過筆者寫完這些看到篇幅大小可能搶先嚇到脫毛也說不定),事實上只要對於 TypeScript 對 JSON 物件的推論機制理解過後,要破解其他不同物件型別的推論機制也是挺容易的!

我們將延續前篇文章建置的環境,同樣在 01-basic 資料夾內的 index.ts 進行示範,因此,如果你有留下前一篇內容所寫過的程式碼,理應應該會出現類似這樣的畫面。(如圖一)

https://ithelp.ithome.com.tw/upload/images/20190912/20120614iMeGzzaXy4.png
圖一:編輯器裡的 01-basic 檔案資料夾目前除了有 index.ts 外,可能你會有經過 TypeScript 編譯過後的結果在產出的 index.js 裡。

貼心小提醒

如果讀者想要索取本系列文章的程式碼,可以進到 GitHub 的這個 Repo 觀看喔~ 至於 README 部分筆者也會找時間再更新說明的。

基礎物件型別 - JSON 物件格式

我們先來看看純 JSON 格式物件直接被定義到底會發生什麼事情:

// index.ts

let info = {
  name: 'Maxwell',
  age: 20,
  hasPet: false,
};

有些讀者可能以為:“啊~應該會是object之類的東西吧!”

結果是 —— TypeScript 才沒這麼懶惰,它還親自推論之後提醒你:“這個變數,它還存在 nameage 以及 hasPet 的屬性,對應型別為字串、數字以及布林代數。”(圖二)

https://ithelp.ithome.com.tw/upload/images/20190912/20120614eFpSpJ9LPq.png
圖二:TS 還真的幫我們辨識出物件的屬性對應的型別呢!

正當我們不用再幫物件進行任何型態註記而歡呼時,我們又再次因為那 Nullable Type 而開始擔心起來,不過奇蹟依然降臨。(結果如圖三)

// index.ts

let someone = {
  knows: undefined,
  identity: null
};

https://ithelp.ithome.com.tw/upload/images/20190912/20120614dR72wVqfsk.png
圖三:結果就算是 Nullable Type,結果得到應該被推論出來的結果,而非 any

為何 Nullable Type 在物件裡面作為屬性的值就不會推論成 any 型別呢?

詳細情形,筆者也實在很難說出個所以然。但筆者認為,如果我是開發 TypeScript 編譯器的人(但筆者沒這麼厲害 QQ),應該會很希望就算單純指定 null 為某個變數的值,也可以直接型別推論為 null 型別,而不是 any

不過這樣做有個壞處,就是會對 undefined 這個型別有爭議,如果我們選擇遲滯性指派(Delayed Initialization)某變數的話,一開始就會被強制推論成 undefined 而不能被指派任何值;此外,為了銜接暫時性死區(TDZ)的概念,最後的選擇是:只要任何變數被指派 Nullable Type 就會選擇予以忽略(或者是被推論為 any 型別,畢竟 TS 在讀到那段程式碼哪知道開發者腦袋在想什麼)。

好喔!以下就來試試看如何讓 TS 感到莫名其妙~

第一情況:屬性值被錯誤的型別插入/覆寫干擾(認錯型別就跟認錯人一樣丟臉)(圖四)

https://ithelp.ithome.com.tw/upload/images/20190914/20120614N5Y1vxtolH.png

https://ithelp.ithome.com.tw/upload/images/20190912/20120614zEBNpjtDF2.png
圖四:恩!一致性地確認通報給我們了!

第二情況:物件分別被正確或者是錯誤的物件格式整體複寫(簡直就是整體形象被取代或大打折扣)(圖五~圖七)

https://ithelp.ithome.com.tw/upload/images/20190914/20120614DsnMCKLHiu.png

https://ithelp.ithome.com.tw/upload/images/20190912/20120614QaBzy4gBDO.png
圖五:結果是不是跟你預期的一樣呢?

https://ithelp.ithome.com.tw/upload/images/20190912/20120614Kr0mlpd2Hh.png
圖六:格式少一鍵會出錯

https://ithelp.ithome.com.tw/upload/images/20190912/20120614IUenk1lGBn.png
圖七:格式多一鍵也出錯

結論是 —— 只要格式正確,TypeScript 都會給你通過

少一鍵或多一鍵也不行,因此我們可以間接假設:如果直接對物件新增某屬性,就會被 TypeScript 警告。

第三情況:直接對物件新增值(圖八)

https://ithelp.ithome.com.tw/upload/images/20190912/20120614wwmTAadQdA.png
圖八:新增不明的值確實會引起 TS 關注喔

莫名其妙踩到雷:當物件的屬性被刪除時,儘管物件格式不符合型別應該所期待,卻仍然能夠動作!?

delete info.hasPet;
console.log(info);

如果讀者敏銳一點的話,就會出現一個疑問:怎麼不順便介紹刪除屬性的部分呢?如果讀者嘗試的話就會發現真的可以編譯,但編譯出來的結果卻是 —— 格式錯誤,仍舊可以執行

這其實要深入會是一個更麻煩的坑,請參考這個 PR:Deleting a Prop Doesn't Invalidate the Interface,簡短的答案就是:這個 Bug 已經被 Report 兩年了,還沒有被修復(圖九),要深入可以關注一下這個 Issue,可能在某個將來會有勇者把這個奇怪的 Bug 給解掉!

https://ithelp.ithome.com.tw/upload/images/20190912/20120614EuxQWW1Ydm.png
圖九:這位開發者驚呼了一聲:“I'm really surprised this bug hasn't been picked up in over 2 years.”

不過也有人認為是 TypeScript 故意這樣設計的,不過對筆者來說也是挺弔詭的一件事。筆者認為刪除屬性的簡易替代方案是:

info.age = undefined;

不過在這個範例裡,TS 一定會疾呼錯誤的:因為 TS 知道該屬性應該接收的型態並非 undefined(型別推論)但我們偏偏想硬塞 undefined 進去。再者,代入 undefined 值的寫法跟 delete 寫法仍然會有一些微妙的差別。(圖十)

https://ithelp.ithome.com.tw/upload/images/20190912/20120614CqNXCMez25.png
圖十:莫名其妙,難道就不能好好幫人家警告一下這種方式寫錯嗎?

重點 1. 基礎物件的型別推論機制

  1. JS 物件的型別會按照物件本身的格式被推論出來
  2. 可以對物件做出的行為:
  • 對物件裡的屬性覆寫值,其值的型別與該屬性對應的型別相同
  • 對物件整體覆寫,其覆寫的物件格式必須完全相同
  1. 常見會被 TS 警告的情形有以下:
  • 對物件裡的屬性插入或覆寫錯誤的型別值
  • 覆寫整個物件時的格式錯誤(少一鍵 / 多一鍵 / 沒多沒少鍵,但至少其中一鍵對應值之型別錯誤)
  • 隨意新增原先不存在該物件的屬性
  1. 物件的屬性若直接代入 Nullable Type,則不會被視為 any 型別,而是等同於該 Nullable Type 本身的值(undefined 型別的值就是 undefinednull 型別的值就是 null
  2. delete 目前在 TS 就算被使用在刪除物件屬性上,TS 依舊不會警告你 (參見這個 Issue)(這個行為可能隨時隨地會被更改掉,不過不知道是什麼時候

讀者試試看

基本上,筆者把常見情況都帶過了。如果要排列組合的話,還有很多種 Case 可以討論,暫且就放在這裡讓讀者試試看吧,這樣會對於 TypeScript 推論的機制會有更深層的體會。按照前面所述的常見被警告的三種情況,試著測試看看以下不同的 Case:

  1. 物件包物件
let nestedObject = {
  prop: 'Hello',
  child: {
    prop1: 123,
    prop2: false
  }
};
  1. 物件被展開到另一個物件(須具備 ES7 Rest-Spread Operator 知識)
let obj1 = { hello: 'World' };
let obj2 = { ...obj1, goodbye: 'Cruel World' };
  1. 使用 Object.assign
let obj3 = { hello: 'Another World' };
let obj4 = Object.assign(obj3, {
  goodbye: 'Cruel World'
});

範例 3 難度較高,有些讀者甚至會在剛開始呼叫 Object.assign 這邊時就已經被 TypeScript 警告,筆者剛開始也感到很莫名其妙,不過這跟 TS 編譯器的設定有關,之後會再做細部討論。

若想先暫時解決掉這個問題,可以查看 Stackoverflow討論串,亦或者是把 tsconfig.json 裡的 lib 選項啟動,改成:

// tsconfig.json
{
  "compilerOptions" : {
    /* Basic Options */
    ...
    "lib": ["es2015", "dom"],
    ...
  }
}

不過呢,這東西推論出來的結果實在是太噁心了,讓筆者也不禁打了寒顫!如果認真的讀者看到了範例 3 推論出來的結果,筆者會簡單的跟你說:以後不要隨隨便便用 Object.assign 找自己麻煩,建議用前一個範例的 Rest-Spread Operator 作為替代方案會是可以預期的結果

object 型別註記

這裡要探討另一個很奇怪的問題 —— 既然推論都可以推論出物件格式了,那 object 在 TS 是可以被註記的型別嗎?

結果答案是可以的!

看到這裡,我們可能以為這就跟對變數宣告 any 好像沒太多差別,只是是物件版本的 any 型別。不過呢,沒有驗證過總是不知道行為是什麼,因此我們來測測看(結果如圖十一):

https://ithelp.ithome.com.tw/upload/images/20190914/20120614MkG5YzEDOO.png

https://ithelp.ithome.com.tw/upload/images/20190912/20120614RTyloFUxtt.png
圖十一:看來呢,還是有差!而且原本筆者認為正確的情況是錯的!

結論是 —— 只有第二種情況,完全覆寫是可以被 TypeScript 接受的!

TS 認為 object 型別指的是任何 JS 物件(儘管格式不同)都可以被套入,但是不允許對該物件做細部微調(連覆寫某型別的值,其型別跟物件本身擁有屬性對應的型別相同,將那個值覆寫進去也不行!),要覆寫就得全部覆寫!這個概念跟 Immutable 感覺很像,我們不能輕易更改內部設定,要更新就得全部更上去。

藉由以上結論,筆者在這裡又要假設一件事情:

既然允許物件全部被覆寫的話,在 JS 裡:Array 或 Function 也是 JS 物件的一種表示方式啊,是不是就代表 Array / Function 也可以覆寫在 object 型別下呢

乾脆我們也豁出去把我們能夠想像到的狀況一次搞定。

不過到這裡,筆者想要將 JS 物件這個名詞再澄清的更精確一點,這些是筆者在這系列為了解說方便才會用到的詞彙,並不是公認的 JS 圈的詞彙喔:

  • 狹義物件的定義:僅限於 JSON 格式的物件(典型的 {} 這種東西的寫法)
  • 廣義物件的定義:包含 JSON 格式的物件、陣列、函式、類別、類別創建出之物件

以下程式碼就來驗證看看。(結果如圖十二)

https://ithelp.ithome.com.tw/upload/images/20190914/20120614AnUPE4IP5u.png

https://ithelp.ithome.com.tw/upload/images/20190912/20120614lanBfkPkyh.png
圖十二:除了很明顯的原始型別不是物件外,全員一致 PASS!

重點 2. 型別註記 object

let A: object;

假設 A 為某個被註記成 object 型別,則:

  1. A 可以被任何廣義物件覆寫
  2. A 一但被代入任何廣義物件,我們只能進行全面覆寫,不能進行微調動作,包含:新增屬性、改變屬性的值
  3. A 一但被代入任何廣義物件,全面覆寫的格式不限定,只要屬於廣義物件都可以

延伸假設:物件的完整性

筆者已經把基礎物件的推論跟註記(也就是 object)分別幫讀者驗證過了。

不過讀者應該可以感覺得到 —— 不需要主動註記,TS 本身對於基礎物件的推論對開發者來說足矣

畢竟 TS 推論狹義物件的結果,比起單純用 object 註記好太多了:不僅不能亂覆寫成其他物件格式外,也不能隨隨便便對物件新增或刪除屬性,要求維持物件的完整性

這裡筆者再針對完整性這個詞做一個更精確的說明:

Hypothesis:狹義物件的完整性

泛指狹義物件在被 TypeScript 推論的狀態下,屬性不能被任意新增或更改成其他型別,我們能夠做的事情只有:

  1. 全面覆寫,狹義物件的屬性對照型別格式也要完全對位
  2. 更改狹義物件本身就擁有屬性對應的值,其中:要帶入的值的型態必須對應到該屬性的型態

我們稱這樣的行為為「保持狹義物件的完整性」。

既然筆者假定:如果遇到狹義物件並且請 TS 進行型別推論,TS 就會禁止我們破壞該物件的完整性

那這裡筆者就想試試看,如果我們把狹義物件換成廣義物件會發生什麼事情呢?因此我們要來驗證看看以下的情形(結果如圖十三)。

https://ithelp.ithome.com.tw/upload/images/20190914/20120614b0Ulw0NS0s.png

https://ithelp.ithome.com.tw/upload/images/20190912/20120614KtPsJ5f5Vy.png
圖十三:驗證成立!

結果真是令人振奮,我們的假設從狹義物件推廣到廣義物件是成立的!我們可以把以上這些 Case 匯聚成一個可以清楚理解的定律:

重點 3. 廣義物件完整性定律

廣義物件在被 TypeScript 推論的狀態下,屬性不能被任意新增或更改成其他型別。

能夠做的事情只有:

  1. 全面覆寫,廣義物件的屬性對照型別格式也要完全對位
  2. 更改廣義物件本身就擁有屬性對應的值,其中:要帶入的值的型態必須對應到該屬性的型態

我們稱這樣的行為為「保持廣義物件的完整性」。

這個定律會在後續不停的用到,這使得我們可以大膽預測 TypeScript 對於其他物件 —— 包含陣列、函式等等 —— 的推論機制而不需要再去翻官方文件。這也是為何筆者可以依照經驗法則來學習 TypeScript 的推論機制。

小結

最後,筆者在澄清一下:

之所以會定立這些規則(例如:廣義物件和狹義物件的定義、廣義物件完整性定律)的目的並不是因為這些東西真的是 TypeScript 標準或 JS 圈的名詞,而是筆者藉由不斷去實驗,得知 TypeScript 在物件的推論上有這樣的特性而整理出來的規則。因此這些並不是 TypeScript 官方提出的一個標準,而是把各種案例串起共通點的結論喔!

不過讀者可能會問:“那這些規則會不會有例外呢?”

筆者認為,有的機率應該**很大!**不過有這些規則的好處是:

  1. 方便記憶
  2. 可以涵蓋大部分的狀況
  3. 快速理解過後,根據已知的 TypeScript 行為推論未知的部分(比如:在這邊光是只要把狹義物件的案例測過,將假設推廣至廣義物件時,幾乎可以無縫接軌預測出 TypeScript 對於任何物件的推論結果,不需要再對其他不同物件一個個進行測試)

如果真有例外,我們可以再對這些規則進行修改,亦或者是特別指名這些雷點後 —— 儘量避開這些灰色地帶

畢竟在開發時,如果還很介意這些蒜末細節,反而會導致開發時間浪費在一些很莫名其妙的地方。

相信讀者今天來學 TypeScript,最怕就是學完之後依然踩到雷,而筆者能做的就是把踩過的雷整理起來,讓讀者不要再誤入那些陷阱。


上一篇
Day 02. 前線維護・型別推論 X 註記 - Type Inference & Annotation
下一篇
Day 04. 前線維護・函式型別 X 積極註記 - Function Types
系列文
讓 TypeScript 成為你全端開發的 ACE!51
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
flier268
iT邦新手 5 級 ‧ 2020-11-19 15:02:19

關於undefined的問題已經在Typescript 4解決在issue的最後面有提到
還真是拖夠久了,4年阿!

2
良葛格
iT邦新手 2 級 ‧ 2021-12-20 11:22:37

以下是一些我對名詞的想法,加減參考。

基本上,JSON 作為資料交換格式確實是參考 Object literal 而來,不過 JSON 就是 JSON,Object literal 就是 Object literal,很少聽過「JSON 物件格式」這個名詞。

例如,使用字串以 JSON 格式建立一筆資料:

let json = '{"name": "caterpillar", "age": 18}';

例如,用 Object literal 建立物件:

let obj = {name: 'caterpillar', age: 18};

你文中的「JSON 物件格式」,應該是「Object literal」。

至於 obj 所謂的「物件格式」,一般來說,更常使用的是「物件結構」這個詞,因為有所謂結構定型(Structural typing)這個名詞,例如,obj 型態具有 name: stringage: number 的結構。

0
shiaobin
iT邦新手 4 級 ‧ 2023-05-24 17:14:38

關於「莫名其妙踩到雷:當物件的屬性被刪除時,儘管物件格式不符合型別應該所期待,卻仍然能夠動作!?」這一段,剛剛試了一下,發現 TypeScript 是會報錯的。應該是這個 bug 已經修好了吧。
'delete' 運算子的運算元必須是非必須

我要留言

立即登入留言