iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 2
4
Modern Web

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

Day 02. 前線維護・型別推論 X 註記 - Type Inference & Annotation

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

《前線維護》篇章概要

第一次看到 TypeScript 的人,最先看到就是型別註記(以下會把程式碼有註記的部分用 ~ 顯示出來)—— 英文稱為 Type Annotation —— 很重要,因為要查找任何跟型別系統有關的資源,這單字會不停地出現。

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

不過這些都還只是非常非常表面而已,型別系統(Type System)的用意就是要讓開發者能夠發現我們到底不小心在哪裡寫錯程式碼,除錯過程較為簡單外,也不需要很煩悶的不停執行 -> 看錯誤訊息 -> 翻程式碼 -> 好!找到了!改!-> 然後結果下次執行後還有下一段錯誤 -> 看錯誤訊息 -> 翻程式碼 -> WTx!原來是這裡出錯!再改!

讀者後來會發現本系列,講解語法的過程中很少會需要編譯,目的是要能夠讓 TypeScript 編譯器自動指出我們寫錯的地方

但是要能夠掌握這個系統,寫出穩紮穩打的程式碼,勢必要理解型別的推論(Inference)以及註記(Annotation)的原理以及應用時機:

Type Inference 與 Annotation 的 4W1H

What:推論與註記到底為何?
Where:到底什麼地方會用到推論與註記?
When:到底什麼時候會用到推論與註記?
Why:為何我們要認識推論與註記的機制?
How:如何善用推論與註記?

本日正文開始

型別有哪些?

在回答型別推論與註記在 TypeScript 運作的機制之前,要先知道 TypeScript 內建定義了哪些基礎型別(Types)。

筆者大致上劃分了幾個類別,個人覺得倒是挺多種的(汗水看了不停直流):

  • 原始型別 Primitive Types:包含 number, string, boolean, undefined, null, ES6 介紹的 symbol 與時常會在函式型別裡看到的 void
  • 物件型別 Object Types,但我個人還會再細分成小類別,但這些型別的共同特徵是 —— 從原始型別或物件型別組合出來的複合型態(比如物件裡面的 Key-Value 個別是 stringnumber 型別組合成的):
    • 基礎物件型別:包含 JSON 物件,陣列(Array<T>T[]),類別以及類別產出的物件(也就是 Class 以及藉由 Class new 出來的 Instance)
    • TypeScript 擴充型別:即 Enum 與 Tuple,內建在 TypeScript
    • 函式型別 Function Types:類似於 (input) => (Ouput) 這種格式的型別,後面會再多做說明
  • 明文型別 Literal Type:一個值本身也可以成為一個型別,比如字串 "Hello world" —— 若成為某變數的型別的話,它只能存剛好等於"Hello world"` 字串值;但通常會看到的是 Object Literal Type,後面也會再多做說明
  • 特殊型別:筆者自行分類的型別,即 anynever(TS 2.0釋出)以及最新的 unknown 型別(TS 3.0釋出),讀者可能覺得莫名其妙,不過這些型別的存在仍然有它的意義,而且很重要,陷阱總是出現在不理解這些特殊型別的特性
  • 複合型別:筆者自行分類的型別,即 unionintersection 的型別組合,但是跟其他的型別的差別在於:這類型的型別都是由邏輯運算子組成,分別為 |&
  • 通用型別 Generic Types:留待進階的 TypeScript 文章介紹,一種讓程式碼可以變得更加通用的絕招

[2019.09.21 新增] 貼心小提示

想要跳到不同型別種類的推論與機制,這裡筆者整理出連結:

這邊給讀者們總覽 TypeScript 支援的型別,後續將會解釋以上型別的正確運用方式。

void

另外,筆者針對為何將 void 放到原始型別作一個補充:void 在筆者看來也可以放置在特殊型別的定義裡,但是由於它具有明確的意思 —— 代表某函數為不回傳值的狀態 —— 和 undefined 有點類似,因為函數不回傳任何東西就跟回傳 undefined 有點相近,不過在習慣上,我們都會以 void 作為函數不回傳任何值的代表性特徵喔。(Day 04. 會再詳細說明)

從型別中學習隱藏在 TypeScript 的機制

“作者一直提醒一直講:型別推論與註記,雖然註記部分看起來有介紹到,但到現在還沒看到型別的推論(Inference)啊!”(拍桌)

這裡先跟大家說明,型別的推論到底什麼時候會用到呢?

其實呢,使用者也不需要主動去用,一直以來,TypeScript 就在為您關照、推論那你寫的變數的型別,自動幫您監測它們!聽起來很窩心吧~* ^_^ *~

所以型別推論是 TypeScript 的被動技

然而,使用者也是會讓 TypeScript 感到沒輒的時候,就算是 TypeScript,它也沒辦法完全進到你的心幫你把功能都確認好,所以我們要培養好習慣,和 TS 成為好兄弟,而不是把 TS 當煙蒂來看待。(不要隨意使用 any

那這裡就有問題了,TS 到底是怎麼猜你寫的型別到底是什麼呢?後面就要進行程式碼的講解,不過我們先建好環境。

首先,上一篇已經創建好的 typescript-tutorial 資料夾內部,再開新的資料夾 —— 筆者取其名為 01-basic。(打開編輯器結果如圖一所示)

https://ithelp.ithome.com.tw/upload/images/20190911/20120614KP7gh0nmKY.png
圖一:新增 01-basic 資料夾在我們的 typescript-tutorial 資料夾裡

貼心小提示

不想再另開終端機的話,可能有些人早就知道要如何處置,不過這裡筆者還是提醒一下。

我們可以在 VSCode 上方有一個名為 Terminal 的選項開啟終端機。該終端機介面會在編輯器下方的面板裡連上。(如圖二)
https://ithelp.ithome.com.tw/upload/images/20190911/20120614qOfnclRM0X.png
圖二:點擊上方 Terminal 選項部分新增終端機的介面在我們的 IDE 裡

記得,我們必須進到 01-basic 這個資料夾裡面喔!不是在 typescript-tutorial 那一層!然後這是第二次提醒如何初始化 TypeScript 編譯器的設定檔,往後筆者會快速提及並且跳過建置環境的細節,除非我們要使用其它特定的模組或對 tsconfig 進行特殊設定。(結果如圖三)

// 沒有進到 01-basic 資料夾記得:
$ cd ./01-basic

// 初始化 TS 編譯器設定檔
$ tsc --init

https://ithelp.ithome.com.tw/upload/images/20190911/201206141buM9rcsPo.png
圖三:下達基礎指令過後,蹦出的 tsconfig.json 檔案

[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 喔~寫作過程當中會不斷更新的!

原始型別 Primitive Types

型別推論在原始型別的運作

好的,一開始先建立好 index.ts,再來開始寫程式碼吧!

首先要把基礎的東西講清楚,相信讀者也會認為這邊都很簡單(呼呼~等到介紹 interface 以及 class 才會很精彩),因此可以快速看過。

當然,我們可以看到下面這段程式碼編譯過後可以正常執行:

// index.ts
let myName = 'Maxwell';
let age = 20;
let hasPet = false;
let nothing = undefined;
let nothingLiterally = null;

不過這裡我們要注意一個重點 —— 筆者並沒有對這些變數的定義做型別的註記(Type Annotation),但是我們可以在 IDE 上開始呼叫我們的變數名稱,譬如圖四所示。

https://ithelp.ithome.com.tw/upload/images/20190911/20120614IZxzRAhvWv.png
圖四:呼叫我們的 myName 變數

結果 TS 除了認得定義過後的變數外,也認得該變數型別是 string(出現了 let myName: string 的說明),這個辨識推論型別的行為就是所謂的型別推論(Type Inference)

讀者試試看

試試看其他的變數,比如 hasPet 這個變數,結果也會順順利利地推論出為 boolean 的型別。不過到最後你會發現一些有趣的現象。

這時候我們就卡關了 —— 我們如果對 nothingnothingLiterally 這兩個型別作確認的話,結果 TypeScript 會把它們推論成 any 型別(如圖五與圖六)。

https://ithelp.ithome.com.tw/upload/images/20190911/20120614Xn2ScArQWU.png
圖五:nothing 被 TS 當成 any

https://ithelp.ithome.com.tw/upload/images/20190911/20120614lDb8uHf6yp.png
圖六:nothingLiterally 也被 TS 當成 any

這種 nullundefined 類型的東西英文又被稱為 Nullable Types,我們會在後續跟 TS 編譯器設定部分章節會再次提及,不過讀者先記好這種 Nullable Types 被認為是 any 型別的特性就 OK 了。

但是型別推論的真正用意是 —— 如果設法指派其他型別的值到被推論後的變數的話,TypeScript 會提醒你並顯示警告。(如圖七)

https://ithelp.ithome.com.tw/upload/images/20190911/20120614KpHPnFRhn0.png
圖七:我們將字串丟進被 TS 認為是存 number 的變數裡

TS 直接對我們的行為提出質疑 —— 認為 age 這種東西應該要放數字,不是字串。

重點 1. 型別推論的目的 Purpose of Type Inference

讓 TypeScript 協助我們確保不會做錯事情 —— 也就是不小心把不同型別的東西丟到被推論過後的變數

讀者試試看

接下來,你可以試試看對其他變數做任何型別的存取動作,如果讀者細心一點,你也將會發現神奇的事情。

根據圖八的結果,會發現本來就被推論為基本的數字、字串或者布林代數型別的變數,在給予其他型別的值,不外乎都會被 TS 提出質疑(紅色的~~~~所顯示部分)。

然而,Nullable Types 就是會跟著我們作對,我們對 Nullable Types(或者是被推論為 any 型別的變數)進行變數的指派動作都不會被 TS 視為警告。

https://ithelp.ithome.com.tw/upload/images/20190911/20120614iOYbgqyiSL.png
圖八:針對不同的變數,隨便帶入不同型別的值進去所得出結果

更扯的是 —— 我們的 Nullable Types 或者被視為 any 型別的變數可以隨隨便便地使用。(如圖九)

https://ithelp.ithome.com.tw/upload/images/20190911/201206144EiMQkd2ie.png
圖九:any 型別愛怎麼變就怎麼變,TS 也拿它們沒輒

重點 2. any 是造成型別混亂的根源

Nullable Types 會被推論為 any 型別,而 any 在 TypeScript 裡會無法監督變數狀態,造成程式碼的混亂。

因此我們應當儘量避免這件事情的發生:變數的型別被視為 any

那讀者可能會問:『 我們有沒有情況會用到 any呢? 』

極少數狀況會用到。

不過剛開始用 TypeScript,最好能夠避免就儘量避免,不然你沒有讓 TS 發揮真正的優勢的話,也根本就不需要 TS 幫你監督程式碼。(筆者表示:完全是開發者自律體現的哲學啊~)

遲滯性指派 Delayed Initialization

其實還有另一種狀況會出現 any,在英文裡稱這個現象為 Delayed Initialization(筆者想取一個很酷的中文名字,所以就稱它為遲滯性指派)。

這也挺常見,讀者當初學習原生 JS,定義變數時,可能早就碰過:你先定義變數後,不直接指派值,而是當程式碼執行到後面才開始指派。比如:

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

其中 TS 一開始就已經認定好 messageToSend 就是 any 型別。因此不管你後續代入什麼值進去,TS 都無所謂,完全放棄閒置狀態。(圖十跟圖十一)

https://ithelp.ithome.com.tw/upload/images/20190911/20120614R2Zr46agaz.png
圖十:一開始不跟 TS 講好是什麼東西的話,推論都會是 any 型別

https://ithelp.ithome.com.tw/upload/images/20190911/20120614oCViNdtO8s.png
圖十一:對於這種類型,TS 根本不想鳥你,實在是有夠狠

其實原理很簡單:你對剛定義出的任何變數沒有帶入值的話,就等同於帶入 undefined 這個值的概念,也就回到我們剛剛所講的 Nullable Type。因此我們可以得出第三個重點:

重點 3. 遲滯性指派 Delayed Initialization

每當我們對任何變數不立即指派值,該變數會無條件被視為 any 型別。

原始型別的註記

因此,為了避免有 any 型別的狀態發生,應當對這些被指派 Nullable Types 的變數或者不立即被指派值的變數做型別註記 —— 也正是我們剛開始提到的 Type Annotation。

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

如果將以上的程式碼寫入 TS 檔,你會發現 TS 會限制這兩個變數 —— 不被其他型別取代。(如圖十二)

https://ithelp.ithome.com.tw/upload/images/20190911/20120614LcjimuYJQD.png
圖十二:這一次 TS 終於認知到這兩個變數不能被其他型別給取代

具有型別但是預設為“空”值或 Nullable Types

如果今天想要讓某變數除了可以是 Nullable Types(可能真的就是代表空值),同時又是 —— 比如說,字串 string 這個型別。

以下測試將變數變成 string 型別但是沒有指派值。(如圖十三)

https://ithelp.ithome.com.tw/upload/images/20190911/20120614j1F4CSO1wu.png
圖十三:先不指派值但是給予型別

你會發現,給它字串,它就可以正常運作。

然而再清除為 nullundefined 時又出現錯誤。其中的原因是:TS 已經認證該變數必須得是 string 的型態。

不過一些細心的讀者可能會發現:如果我們在還沒指派值之前先用變數的話,不就等同於它是 undefined 的值嗎?為何它不會在一開始就會拋出問題。筆者就來嘗試以下的程式碼:

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

你會發現 TS 這一次拋出了質疑(圖十四)。

https://ithelp.ithome.com.tw/upload/images/20190911/201206149US2LwFt1r.png
圖十四:在還沒帶入值之前,中間如果被呼叫的話就會出現問題

哦!原來,TS 至少還會認得這個問題,這跟 TDZ (Temporal Dead Zone,暫時性死區)的概念還蠻像的。參照這個 Stackoverflow 的提問,裡面有句話把 TDZ 概念解釋得很清楚:

..., let and const are hoisted (like var, class and function), but there is a period between entering scope and being declared where they cannot be accessed. This period is the temporal dead zone (TDZ).

裡面提到 hoisted 這個單字,在理解 TDZ 時,讀者必須了解 JS 的變數作用域以及變數提升的概念。

由於這些是原生 JS 跟 ES6 let 的範疇,因此筆者不多作解釋。如果讀者不知道這個特性可以參考我放在小結最後的補充資料連結。反正這裡先丟個簡單的小結論:在未確定變數被正式丟入合法的值之前的這段空間,不能使用該變數

讀者試試看

請問這段程式碼將 let 改回 var 是不是也會出現類似的狀況呢?這個就由讀者來自行驗證看看囉。
https://ithelp.ithome.com.tw/upload/images/20190914/20120614MunA2sYkaT.png

注意

筆者常犯的英文拼寫錯誤,TDZ 全名是 Temporal Dead Zone,不是 Temperal

可是我們原先的目的是想讓變數除了可以成為我們指定的型別外,也可以成為 Nullable Type。這時候我們就需要特別使用 union 將該變數註記為 <YOUR-TYPE> | <nullable-type> 的格式。(如圖十五)

https://ithelp.ithome.com.tw/upload/images/20190911/20120614YNnD8KmMnL.png
圖十五:絕對可以同時成為 string 或者是 null

由於我們已經讓該變數也可以成為 Nullable Type,最好的建議就是:除非可以直接指派值,否則初始化時,就指派 Nullable Type 的值。

重點 4. 對遲滯性指派進行型別註記

let A: T;

A = B as T;

若使用者宣告變數 A,其中沒有對 A指派值但明確給它型別註記 T,而後再把型別也是 TB 變數代入 A 的話:

  1. 就算 A 是非 any 型別但一開始是 undefined 的狀態,TS 仍然不會對你有什麼太大意見
  2. 真正有意見時,是在你指派具 T 型別的值(也就是 B)到 A 裡面前,你就對 A 做其他行為,TS 會自動跟你槓上(TDZ 的概念)
  3. 基本上,對 A 有註記跟沒註記 T 型別差別僅僅只是防止變數 A 被 TS 冷落(也就是被推論為 any),但也因為這樣我們才能找回 TS 對 A 變數的關注,防止我們不小心弄錯 A 的型別

重點 5. 型別註記的目的

  1. 其中註記最大的好處,除了是讓開發者明確知道變數固定在哪個型別外,TS 也可以不用猜就知道要怎麼幫我們關注該變數
  2. any 這個禍根給剔除

重點 6. 型別註記與推論 Type Annotation & Inference

  1. TS 基本上會把型別推論做得很好,你也不太需要擔心說:“如果我忘記註記那裡我會不會得不到 TS 的關切?”,這問題對 TS 來講小事一樁,它可是很聰明的!
  2. 但是如果對於型別不明確或者是會被推論為 any 狀態的變數,你可就要積極使用型別註記囉,不然 TS 可不幫你處理這部分的事情,你也就不能怪 TS 說:“啊啊啊!這個爛煙蒂根本就沒用!”(那你就跟 TS 好好分手吧)
  3. 型別推論是 TS 自你開始寫程式碼的時候,它就會幫你監控了;然而,型別註記則是開發者必須手動宣告給 TS 看的

小結

光是講一個原始型別(Primitive Type)扯到的東西有夠多,不過這裡除了介紹完原始型別後,也順便用它們來展示 Type Inference 以及 Type Annotation 的機制。

當然,我們後續再談其他類型的推論與註記機制,會經過同樣的討論形式,但每種型別有各種細部問題等著讀者去發掘呢!

補充資料

TDZ 暫時性死區

莫名其妙踩到雷

想想看,如果我們定義變數名稱為 name,為何會在 TS 出錯呢?(有時候會出現錯誤,如果沒有遇到的話也可以想想看是什麼狀況下沒有錯誤)你可以試著上網查看看原因到底為何,這個就留待給讀者延伸探索囉~(如圖十六)
https://ithelp.ithome.com.tw/upload/images/20190911/20120614XoS32gi5fj.png
圖十六:這應該不成問題的啊!?


上一篇
Day 01. 遠征 TypeScript・行前準備
下一篇
Day 03. 前線維護・物件型別 X 完整性理論 - Object Types Basics
系列文
讓 TypeScript 成為你全端開發的 ACE!51

1 則留言

我要留言

立即登入留言