! This post is also available in the following language. 日語

導入 TypeScript 應考慮之效益與成本

大家好,我是京都開發室的 Lin。
在工作與私人專案中使用 TypeScript 開發已約兩年,
想就導入 TypeScript 時的經驗與大家分享。

近年 TypeScript 是前端領域最熱門的一項技術。
根據 The State of JavaScript 的資料,越來越多開發者選擇使用 TypeScript 且評價也趨向正面。

許多團隊會考慮「下個專案應該用 TypeScript 開發」、「把現有 JavaScript 專案改為 TypeScript 有助於提升專案品質」。

然而,關於導入 TypeScript 的成本與報酬,我認為需要非常謹慎地評估。

不可輕忽導入 TypeScript 所需的成本

以個人目前的經驗來說,在熟悉 TypeScript 之後開發的效率約略和使用 JavaScript 是相近的。

雖然因為型別宣告使程式碼更長了一些,但也因為自動補完以及定義查詢而減少了一些查詢文件與程式碼的時間。

在需要重構時相比 JavaScript 專案更是省時而安全。

但對初次使用 TypeScript 開發的團隊而言,我認為需要有相較於以 JavaScript 開發多花費一至兩倍時間的覺悟。

學習成本

如果認為 「TypeScript 就只是 JavaScript 標記了型別的版本,學起來應該不難」的話,很可能會錯估了團隊為適應 TypeScript 所需付出的成本。

對於熟悉 JavaScript 的人來說,因為靜態型別的限制導致一些原本在 JavaScript 中能輕易達成的設計在 TypeScript 中難以實現。
而也由於型別系統和其他靜態語言的設計有許多差異。有 Java、C# 或 Haskell 等其他語言經驗的人,反而會時常感到錯愕。

除了語言本身以外,也需要學習現有的框架與函式庫對應 TypeScript 時有別於 JavaScript 的使用方法。

實際上從入門到熟悉會花上不少時間。

函式庫的型別支援

TypeScript 雖然可以使用 JavaScript 函式庫。
但是現有的 JavaScript 函式庫未必有提供 TypeScript 所需的型別資訊。

在嚴格的編譯設定下,使用未提供型別資訊的函式庫將編譯失敗。

較為熱門的函式庫大多可以從社群維護的 types 中找到對應的型別套件。
然而第三方所提供的型別未必正確,也可能遇到原函式庫更新了版本但相應的型別套件卻沒有更新的情況。

在型別錯誤或不夠完善的情況下,反而可能被錯誤的型別誤導而錯用函式庫或在型別處理上花費大量的時間。甚至可能會因為缺乏 TypeScript 的支援,而不得不選擇放棄使用某些實用的 JavaScript 函式庫。

而對於有提供 TypeScript 支援的框架或函式庫,在使用 TypeScript 也可能需要額外的設定,甚至 API 的形式也有所不同。例如:

  • 在 Vue 2 中,為獲得較好的 TypeScript 支援而使用 class component 形式。
  • Redux Tool 在使用 TypeScript 時需要額外的設定,且部分 API 使用了與 JavaScript 不同的形式。
  • Emotion 需要修改 import 路徑以取得 theme 型別。

無法良好處理部分 JavaScript 的常用模式

在 JavaScript 中常使用不定參數、組合模式與高階函式等方法的話,在使用 TypeScript 的時候將遇到很多阻礙。
有一些想處理相關問題的提議(#1213#16936)至今 (TypeScript 4.0) 都還未得解決。

例如 lodash 的 flow 函式,就是一個 TypeScript 難以處理的例子。

這類的問題導致開發上失去了原本 JavaScript 所提供的靈活性。
使人需要選擇放棄一部分的設計模式,或是花費大量心力在撰寫極為複雜的型別宣告。

在寬鬆與嚴格中的掙扎

TypeScript 有許多編譯選項。此外,ESLint 等工具也有 TypeScript 對應的擴充規則。
常見的建議是「使用最嚴格的設定」,例如禁用 any、使用嚴格的 nullable 檢查等。

但實際經驗上,對初用 TypeScript 而言,在最嚴格的編譯選項下開發是十分艱難而耗時的。
也存在許多現階段不使用 any 或轉型就無法解決的問題。

但若團隊中沒有對 TypeScript 經驗充足的成員,要做出「何時可以放寬限制」的抉擇就十分困難。

可能在開發時不斷嘗試解決型別卻失敗而不得不妥協。
又或在 Code Review 時耗費許多時間在研究與爭論「是否有不使用 any 的其他解決方法」、「是否應該在這行加上 @ts-ignore」等問題。

編譯流程

TypeScript 需要編譯才能執行,且目前的編譯器並不十分迅速。
這導致在變更程式到可以重新測試時需等待一段時間。

以 TypeScript 開發的知名專案 Deno ,就曾因編譯時間過長等相關問題,將部分 TypeScript 程式改以 JavaScript 撰寫 (#6793)。

TypeScript 的程式風格是否符合喜好

有些使用了 TypeScript 後的影響很難說是好或壞。而是取決於開發者的習慣與偏好。

例如型別的宣告,有助於理解型別資訊,但也有人認為會影響程式邏輯的閱讀。

使用了 TypeScript 除了必須加上的型別宣告以外,在使用一些函式時也會因遷就型別支援而出現較為複雜的形式。

為了型別支援而寫出較不自然的程式

以用原生 API 遍歷 post 中的所有 img 標籤的 src 屬性為例。

document.querySelectorAll('.post img').forEach((image) => {
  console.log(image.src);
  // Error:           ^^^Property 'src' does not exist on type 'Element'
});

在第二行因為 image 得到的型別是 Element,無法確保 src 屬性的存在而會有型別錯誤。

為了解決型別問題,需要加上轉型:

(document.querySelectorAll<HTMLImageElement>('.post img').forEach(image => {
  console.log(image.src)
})

但這樣的轉型實際上並不安全,比如將 img 改成也 div 也不會獲得警告。

document.querySelectorAll<HTMLImageElement>('.post div').forEach((image) => {
  console.log(image.src);
});

有一個可以不用額外轉型的技巧是使用 querySelectorAll('img'),如:

document.querySelectorAll('.post').forEach((post) => {
  post.querySelectorAll('img').forEach((img) => {
    console.log(img.src);
  });
});

這樣的寫法雖能獲得更好的型別資訊,但卻不如原本 JavaScript 的形式簡明。

在 TypeScript 專案中,常有因為類似原因而產生的程式碼。

型別宣告佔據了比程式邏輯更多的版面

假設使用了一個外部函式庫提供的函式,並想將部分參數固定,包裝成另一個函式。
使用 JavaScript 的範例如下:

import { sendSomething } from 'some-lib';

export function sendInJson(options) {
  return sendSomething({
    ...options,
    type: 'json',
  });
}

但在 TypeScript 中,如果這個函式庫並未 export 出參數介面。
為了正確宣告 sendInJson 的型別,會需要如下的寫法:

import { sendSomething } from 'some-lib';

type SendSomethingOptions = Parameters<typeof sendSomething>[0];
type SendInJsonOptions = Omit<SendSomethingOptions, 'type'>;

export function sendInJson(options: SendInJsonOptions) {
  return sendSomething({
    ...options,
    type: 'json',
  });
}

在上例中,一半左右的程式只是為了宣告型別資訊而存在。而當遇到更複雜的型別時,這個問題則愈加嚴重。

TypeScript 帶來的效益值得嗎

選擇使用 TypeScript,期望帶來的效益有:

  1. 靜態型別檢查,可以在編譯時檢測到部分的型別錯誤。
  2. 更好的編輯器功能,如重新命名、定義查詢和自動補完等。
  3. 一目瞭然的型別宣告,提升程式碼的可讀性。
  4. 若是在開發供他人使用的函式庫,提供型別定義會給使用者更良好的開發體驗。

我認為 TypeScript 確實提升了編輯程式時的體驗,在重構時也更加快速而安全。
這也是為什麼許多人會推薦使用的原因。

但其效益使否有如預期,可再多加評估。

靜態型別檢查並不完全安全

靜態型別檢查可以減少動態型別檢查,但並無法完全取代之。
若因為已有 TypeScript 的靜態型別檢查,而以為可以不用做再動態的型別檢查反而會衍生更多問題。

例如使用如下的範例來取得資料:

const response = await fetch(dataSource);
const data: MyData = await response.json();

由於 response.json() 回傳的是 Promise<any> 型態,在此不會有型別錯誤。

此後任何使用 data 的地方都會相信他確實屬於 MyData
實際上卻有可能是不符合預期的。

此外,即使禁用了 any 也妥善處理了每個來自外部或原生 API 中的 any 型別。TypeScript 的型別系統仍非完全安全的 (#9825)。

好的編輯體驗,不一定需要寫 TypeScript

以自動補完來說,寫 TypeScript 能夠獲得良好的支援。
但其實就算只寫 JavaScript 也能做到,當然正確性可能不如 TypeScript 完美。

例如用 WebStorm 開發 JavaScript 時就有不錯的推導功能。你也可以寫 JavaScript 和 JSDoc,利用 TypeScript Language Server 來獲得接近編輯 TypeScript 的體驗。

雖然方便性略遜一籌,但導入成本相較低廉許多。

有限資源下未必是最好的投資

「TypeScript 能提升我們專案的軟體品質」應該很多團隊是抱持這樣的想法而導入 TypeScript 的吧。

現實中軟體品質難以做到盡善盡美,追求的是在有限的人力與時間下做到最好。
能夠改善軟體品質的手段很多,導入 TypeScript 只是其中一個選項,未必是最值得的投資。

在經驗不足的情況下,錯估 TypeScript 導入成本甚至可能帶來反效果。

例如,因耗費過多時間在 TypeScript 相關的問題上而導致:

  • 能用以設計架構的時程被壓縮。
  • 測試與除錯的時間不足。
  • 錯估時程導致後半段的開發變得匆忙而草率。
  • 沒有餘力處理其他靜態分析工具所檢測到的問題。

那這筆交易其實未必划算。

結論

在導入 TypeScript 時,一些可以參考的評估項目有:

  • 產品的開發時程與未來的維護規劃。
  • 團隊成員是否已對 TypeScript 足夠熟悉。
  • 團隊是否偏好使用靜態型別語言
  • 所使用的技術鏈是否有良好的 TypeScript 支援。
  • 公司對於工程師在學習上願意提供的資源。
  • 專案是否作為函式庫供他人使用。

現在前端技術圈有「使用 TypeScript 是現代潮流」的趨勢。似乎要積極導入才符合時代。

然而它能解決部分問題但也會帶來一些額外的困難。
今天的 TypeScript 相較於兩年已經進步了不少,但也還仍有許多會造成開發時困擾的問題。
也期待未來這些問題能逐步被改善使之更易於使用。

我認為 TypeScript 現階段是一項值得學習但並非必須使用的技術。
可應依據團隊的需求與能力,謹慎評估再做出選擇。