TypeScript:重新發明一次 JavaScript

LeanCloud發表於2020-06-10

作者:LeanCloud 工程師 王子亭

作為一個 Node.js 開發者,我很早便了解到了 TypeScript,但又因為我對 CoffeeScript 的喜愛,直到 2016 年才試用了一下 TypeScript,但當時對它的學習並不深入,直到最近又在工作中用 TypeScript 開發了兩個後端專案,對 TypeScript 有了一些新的理解。

為 JavaScript 新增型別

大家總會把 TypeScript 和其他語言去做對比,說它是在模仿 Java 或 C#,我也曾一度相信了這種說法。但其實並非如此,TypeScript 的型別系統和工作機制是如此的獨特,無法簡單地描述成是在模仿哪一個語言,更像是在 JavaScript 的基礎上重新發明了 JavaScript

究其根本,TypeScript 並不是一個全新的語言,它是在一個已有的語言 —— 還是一個非常靈活的動態型別語言上新增靜態約束。在官方 Wiki 上的 TypeScript Design Goals 中有提到,TypeScript 並不是要從 JavaScript 中抽取出一個具有靜態化語義的子集,而是要儘可能去支援之前社群中已有的程式設計正規化,避免與常見的用法產生不相容。

這意味著 TypeScript 試圖為 JavaScript 已有的大量十分「動態」的特性去提供靜態語義。一般認為「靜態型別」的標誌是在編譯時為變數確定型別,但 TypeScript 很特殊,因為 JavaScript 本身的動態性,TypeScript 中的型別更像是一種「約束」,它尊重已有的 JavaScript 設計正規化,同時儘可能新增一點靜態約束 —— 這種約束不會影響到程式碼的表達能力。或者說,TypeScript 會以 JavaScript 的表達能力為先、以 JavaScript 的執行時行為為先,而靜態約束則次之。

這樣聽起來 TypeScript 是不是很無聊呢,畢竟 Python 也有 Type Checking,JavaScript 之前也有 Flow。的確如此,但 TypeScript 的型別系統的表達能力和工具鏈的支援實在太強了,並不像其他一些靜態型別標註僅能覆蓋一些簡單的情況,而是能夠深刻地參與到整個開發過程中,提高開發效率

前面提到 TypeScript 並不想發明新的正規化,而是要儘可能支援 JavaScript 已有的用法。因此雖然 TypeScript 有著強大的型別系統、大量的特性,但對於 JavaScript 開發者來說學習成本並不高,因為幾乎每個特性都可以對應 JavaScript 社群中一種常見的正規化。

基於屬性的型別系統

在 JavaScript 中,物件(Object)是最常用的型別之一,我們會使用大量的物件字面量來組織資料,我們經常將很多不同的引數塞進一個物件,或者從一個函式中返回一個物件,物件中還可以再巢狀物件。可以說物件是 JavaScript 中最常用的資料容器,但並沒有型別去約束它。

例如 request 這個庫會要求使用者將發起請求的所有引數一股腦地以一個物件的形式作為引數傳入。這就是非常典型的 JavaScript 風格。再比如 JavaScript 中一個 Promise 物件只需有 then 和 catch 這兩個例項方法就可以,而並不真的需要真的來自標準庫中的 Promise 構造器,實際上也有很多第三方的 Promise 的實現,或一些返回類 Promise 物件的庫(例如一些 ORM)。

在 JavaScript 中我們通常只關注一個物件是否有我們需要的屬性和方法,這種正規化被稱為「鴨子型別(Duck typing)」,就是說「當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子」。

所以 TypeScript 選擇了一種基於屬性的型別系統(Structural type system),這種型別系統不再關注一個變數被標稱的型別(由哪一個構造器構造),而是 在進行型別檢查時,將物件拆開,抽絲剝繭,逐個去比較組成這個物件的每一個不可細分的成員。如果一個物件有著一個型別所要求的所有屬性或方法,那麼就可以當作這個型別來使用

這就是 TypeScript 型別系統的核心 —— Interface(介面):

interface LabeledValue {
  label: string
}

TypeScript 並不關心 Interface 本身的名字,與其說是「型別」,它更像是一種約束。一個物件只要有一個字串型別的 label 屬性,就可以說它滿足了 LabeledValue 的約束。它可以是一個其他類的例項、可以是字面量、可以有額外的屬性;只要它滿足 LabeledValue 所要求的屬性,就可以被賦值給這個型別的變數、傳遞給這個型別的引數。

前面提到 Interface 實際上是一組屬性或一組約束的集合,說到集合,當然就可以進行交集、並集之類的運算。例如 type C = A & B 表示 C 需要同時滿足型別 A 和型別 B 的約束,可以簡單地實現型別的組合;而 type C = A | B 則表示 C 只需滿足 A 和 B 任一型別的約束,可以實現聯合型別(Union Type)。

接下來我會挑選一些 TypeScript 具有代表性的一些特性進行介紹,它們之間環環相扣,十分精妙。

字串魔法:字面量

在 TypeScript 中,字面量也是一種型別:

type Name = 'ziting'
const myName: Name = 'ziting'

在上面的程式碼中,Name 型別唯一合法的值就是 ziting 這個字串 —— 這看起來毫無意義,但如果我們引入前面提到的集合運算(聯合型別)呢?

type Method = 'GET' | 'PUT' | 'DELETE'

interface Request {
  method: Method
  url: string
}

上面的程式碼中我們約束了 Request 的 method 只能是 GET、PUT 和 DELETE 之一,這比單純地約束它是一個字串型別要更加準確。這是 JavaScript 開發者經常使用的一種模式 —— 用字串來表示列舉型別,字串更靈活也更具有可讀性。

在 lodash 之類的庫中,JavaScript 開發者還非常喜歡使用字串來傳遞屬性名,在 JavaScript 中這很容易出錯。而 TypeScript 則提供了專門的語法和內建的工具型別來實現對這些字串字面量的計算,提供靜態的型別檢查:

interface Todo {
  title: string
  description: string
  completed: boolean
}

// keyof 將 interface 的所有屬性名提取成一個新的聯合型別
type KeyOfTodo = keyof Todo // 'title' | 'description' | 'completed'
// Pick 可以從一個 interface 中提取一組屬性,生成新的型別
type TodoPreview = Pick<Todo, 'title' | 'completed'> // {title: string, completed: boolean}
// Extract 可以找到兩個並集型別的交集,生成新的型別
type Inter = Extract<keyof Todo, 'title' | 'author'> // 'title'

藉助這些語法和後面提到的泛型能力,JavaScript 中各種以字串的形式傳遞屬性名、魔法般的物件處理,也都可以得到準確的型別檢查。

型別超程式設計:泛型

泛型提供了一種將型別引數化的能力,在其他語言中最基本的用途是定義容器型別,使得工具函式可以不必知道被操作的變數的具體型別。JavaScript 中的陣列或 Promise 在 TypeScript 中都會被表述為這樣的泛型型別,例如 Promise.all 的型別定義可以寫成:

function all<T>(values: Array<T | Promise<T>>): Promise<Array<T>>

可以看到型別引數可以被用來構造更復雜的型別,進行集合運算或巢狀。

預設情況下,因為型別引數可以是任意的型別,所以不能假定它有某些屬性或方法,也就不能訪問它的任何屬性,只有新增了約束才能遵循這個約束去使用它,同時 TypeScript 會依照這個約束限制傳入的型別:

interface Lengthwise {
  length: number
}

function logLength<T extends Lengthwise>(arg: T) {
  console.log(arg.length)
}

約束中也可以用到其他的型別引數或使用多個型別引數,在下面的程式碼中我們限制型別引數 K 必須是 obj 的一個屬性名:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

除了在函式上使用泛型之外,我們還可以定義泛型型別:

type Partial<T> = {
  [P in keyof T]?: T[P];
}

當定義泛型型別時我們實際上是在定義一種處理型別的「函式」,使用泛型引數去生成新的型別,這也被稱作「超程式設計」。例如 Partial 會遍歷傳入型別 T 的每一個屬性,返回一個所有屬性都可空的新型別:

interface Person {
  name: string
}

const a: Person = {} // 報錯 Property 'name' is missing in type '{}' but required in type 'Person'.
const b: Partial<Person> = {}

前面我們提到的 Pick 和 Extract 都是這樣的泛型型別。

在此之外 TypeScript 甚至可以在定義泛型型別時進行條件判斷和遞迴,這使得 TypeScript 的型別系統變成了 圖靈完備的,可以在編譯階段進行任何計算。

你可能會懷疑這樣複雜的型別真的有用麼?其實這些特性更多地是提供給庫開發者使用的,對於 JavaScript 社群中的 ORM、資料結構,或者是 lodash 這樣的庫來說,如此強大的型別系統是非常必要的,lodash 的 型別定義 行數甚至是它本身程式碼的幾十倍。

型別方程式:自動推導

但其實我們並不一定要掌握這麼複雜的型別系統,實際上前面介紹的高階特性在業務程式碼中都極少被用到。TypeScript 並不希望標註型別給開發者造成太大的負擔,因此 TypeScript 會盡可能地進行型別推導,讓開發者在大多數情況下不必手動標註型別。

const bool = true // bool 是 true(字面量型別)
let num = 1 // num 是 number
let arr = [0, 1, 'str'] // arr 是 (number | string)[]

let body = await fs.readFile() // body 是 Buffer

// cpuModels 是 string[]
let cpuModels = os.cpus().map( cpu => {
  // cpu 是 os.CpuInfo
  return cpu.model
})

型別推導同樣可以用在泛型中,例如前面提到的 Promise.all 和 getProperty,我們在使用時都不必去管泛型引數:

// 呼叫 Promise.all<Buffer>,files 的型別是 Promise<Buffer[]>
const files = Promise.all(paths.map( path => fs.readFile(path)))
// 呼叫 Promise.all<number[]>,numbers 的型別是 Promise<number[]>
const numbers = Promise.all([1, 2, 3, 4])

// 呼叫 getProperty<{a: number}, 'a'>,a 的型別是 number
const a = getProperty({a: 2}, 'a')

前面提到泛型是在將型別引數化,引入一個未知數來代替實際的型別,所以說泛型對於 TypeScript 就像是一個方程式一樣,只要你提供了能夠解開這個方程的其他未知數,TypeScript 就可以推匯出剩餘的泛型型別。

價值十億美金的錯誤

在很多語言中訪問空指標都會報出異常(在 JavaScript 中是從 null 或 undefined 上讀取屬性時),空指標異常被稱為「價值十億美元的錯誤」。TypeScript 則為空值檢查也提供了支援(需開啟 strictNullChecks),雖然這依賴於型別定義的正確性,並沒有執行時的保證,但依然可以提前在編譯期發現大部分的錯誤,提高開發效率。

TypeScript 中的型別是不可為空(undefined 或 null)的,對於可空的型別必須表示成和 undefined 或 null 的並集型別,這樣當你試圖從一個可能為 undefined 的變數上讀取屬性時,TypeScript 就會報錯了。

function logDateValue1(date: Date) { // 引數不可空
  console.log(date.valueOf())
}

logDateValue1(new Date)
logDateValue1() // 報錯 An argument for 'date' was not provided.

function logDateValue2(date: Date | undefined) { // 引數可空
  console.log(date.valueOf()) // 報錯 Object is possibly 'undefined'.
}

logDateValue2(new Date)
logDateValue2()

在這種情況下 TypeScript 會要求你先對這個值進行判斷,排除其為 undefined 可能性。這就要說到 TypeScript 的另外一項特性 —— 其基於控制流的型別分析。例如在你使用 if 對變數進行非空判斷後,在 if 之後的花括號中這個變數就會變成非空型別:

function print(str: string | null) {
  // str 在這裡的型別是 string | null
  console.log(str.trim()) // 報錯 Object is possibly 'null'.
  if (str !== null) {
    // str 在這裡的型別是 string
    console.log(str.trim())
  }
}

同樣的型別分析也發生在使用 if、switch 等語句對並集型別進行判斷時:

interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}

interface Circle {
  kind: 'circle'
  radius: number
}

function area(s: Rectangle | Circle) {
  // s 在這裡的型別是 Rectangle | Circle
  switch (s.kind) {
    case 'rectangle':
      // s 在這裡的型別是 Rectangle
      return s.height * s.width
    case 'circle':
      // s 在這裡的型別是 Circle
      return Math.PI * s.radius ** 2;
  }
}

僅僅工作在編譯階段

TypeScript 最終仍然會編譯到 JavaScript,再被 JavaScript 引擎(如 V8)執行,在生成出的程式碼中不會包含任何型別資訊,TypeScript 也不會新增任何與執行時行為有關的功能。

TypeScript 僅僅提供了型別檢查,但它並沒有去保證通過檢查的程式碼一定是可以正確執行的。可能一個變數在 TypeScript 的型別宣告中是一個數字,但並不能阻止它在執行時變成一個字串 —— 可能是使用了強制型別轉換或使用了其他非 TypeScript 的庫且型別定義檔案有誤。

在 TypeScript 中你可以將型別設定為 any 來繞過幾乎所有檢查,或者用 as 來強制「轉換」型別,當然就像前面提到的那樣,這裡轉換的僅僅是 TypeScript 在編譯階段的型別標註,並不會改變執行時的型別。雖然 TypeScript 設計上要去支援 JavaScript 的所有正規化,但難免有一些極端的用例無法覆蓋到,這時如何使用 any 就非常考驗開發者的經驗了。

程式語言的型別系統總是需要在靈活和複雜、簡單和死板之間做出權衡,TypeScript 則給出了一個完全不同的答案 —— 將編譯期的檢查和執行時的行為分別看待。這是 TypeScript 飽受爭議的一點,有人認為這樣非常沒有安全感,即使通過了編譯期檢查在執行時依然有可能得到錯誤的型別,也有人認為 這是一個非常切合工程實際的選擇 —— 你可以用 any 來跳過型別檢查,新增一些過於複雜或無法實現的程式碼,雖然這破壞了型別安全,但確實又解決了問題

那麼這種僅僅工作在編譯階段型別檢查有意義麼?我認為當然是有的,畢竟 JavaScript 已經提供了足夠使用的執行時行為,而且要保持與 JavaScript 的互操作性。大家需要的只是 TypeScript 的型別檢查來提高開發效率,除了編譯階段的檢查來儘早發現錯誤以外,TypeScript 的型別資訊也可以給編輯器(IDE)非常準確的補全建議。

與 JavaScript 程式碼一起工作

任何基於 JavaScript 的技術都要去解決和標準 JavaScript 程式碼的互操作性 —— TypeScript 不可能創造出一個平行與 JavaScript 的世界,它必須依賴社群中已有的數十萬的 JavaScript 包。

因此 TypeScript 引入了一種型別描述檔案,允許社群為 JavaScript 編寫型別描述檔案,來讓用到它們的程式碼可以得到 TypeScript 的型別檢查。

描述檔案的確是 TypeScript 開發中最大的痛點,畢竟只有當找全了定義檔案之後,才會有流暢的開發體驗。在開發的過程中不可避免地會用到一些特定領域的、小眾的庫,這時就必須要去考慮這個庫是否有定義檔案、定義檔案的質量如何、是否需要自己為其編寫定義檔案。對於不涉及複雜泛型的庫來說,寫定義檔案並不會花太多時間,你也只需要給自己用到的介面寫定義,但終究是一個分心的點。

小結

TypeScript 有著先進的型別系統,而且這個先進並不是「學術」意義上的先進,而是「工程」意義上的先進,能夠切實地提高開發效率,減輕動態型別的心理負擔,提前發現錯誤。所以在此建議所有的 JavaScript 開發者都瞭解和嘗試一下 TypeScript,對於 JavaScript 的開發者來說,TypeScript 的入門成本非常低。

在 LeanCloud,控制檯在最近的一次的重構中切換到了 TypeScript,提高了前端專案的工程化水平,讓程式碼可以被長時間地維護下去。同時我們一部分既有的基於 Node.js 的後端專案也在切換到 TypeScript。

LeanCloud 的一些內部工具和邊緣服務也會優先考慮 TypeScript,較低的學習成本(誰沒寫過幾行 JavaScript 呀!)、靜態型別檢查和優秀的 IDE 支援,極大地降低了新同事參與不熟悉或長時間無人維護的專案的門檻,提高大家改進內部工具的積極性。

LeanCloud 的 JavaScript SDK、Node SDK 和 Play SDK 都新增了 TypeScript 的定義檔案(並且打算在之後的版本中使用 TypeScript 改寫),讓使用 LeanCloud 的開發者可以在 TypeScript 中使用 SDK,即使不用 TypeScript,定義檔案也可以幫助編輯器來改進程式碼補全和型別提示。

如果你也希望一起來完善這些專案,可以瞭解一下在 LeanCloud 的 工作機會

參考資料:


LeanCloud,領先的 BaaS 提供商,為移動開發提供強有力的後端支援。更多內容請關注「LeanCloud 通訊」

相關文章