TypeScript 型別系統

lucifer發表於2020-08-17

TypeScript 的學習資料非常多,其中也不乏很多優秀的文章和教程。但是目前為止沒有一個我特別滿意的。原因有:

  • 它們大多數沒有一個清晰的主線,而是按照 API 組織章節的,內容在邏輯上比較零散。
  • 大多是“講是什麼,怎麼用“,而不是”講為什麼,講原理“。
  • 大多數內容比較枯燥,趣味性比較低。都是乾巴巴的文字,沒有圖片,缺乏能夠引起強烈共鳴的例子。

因此我的想法是做一套不同市面上大多數的 TypeScript 學習教程。以人類認知的角度思考問題,學習 TypeScript,通過通俗易懂的例子和圖片來幫助大家建立 TypeScript 世界觀。 而本篇文章則是這個系列的開篇。

系列安排:

目錄將來可能會有所調整。

注意,我的系列文章基本不會講 API,因此需要你有一定的 TypeScript 使用基礎,推薦兩個學習資料。

結合這兩個資料和我的系列教程,掌握 TypeScript 指日可待。

接下來,我們通過幾個方面來從巨集觀的角度來看一下 TypeScript。

<!-- more -->

前言

上一節的上帝視角看 TypeScript,我們從巨集觀的角度來對 Typescript 進行了一個展望。之所以把那個放到開頭講是讓大家有一個大體的認識,不想讓大家一葉障目。當你對整個巨集觀層面有了一定的瞭解,那麼對 Typescript 的理解就不會錯太多。相反,一開始就是具體的概念和 API,則很可能會讓你喪失都整體的基本判斷。

實際上, Typescript 一直在不斷更新迭代。一方面是因為當初許下的諾言”Typescript 是 JavaScript 的超集“(JavaScript 的特性你要同步支援,同時也要處理各種新語法帶來的不相容情況)。不單是 ECMA,社群的其他發展可能也會讓 Typescript 很難受。 比如 JSX 的廣泛使用就給 Typescript 泛型的使用帶來了影響。

TypeScript 一直處於高速的迭代。除了修復日常的 bug 之外,TypeScript 也在不斷髮布新的功能,比如最新 4.0.0 beta 版本的標籤元祖 的功能就對智慧提示這塊很有用。Typescript 在社群發展方面也做的格外好,以至於它的競爭對手 Flow 被 Typescript 完美擊敗,這在很大程度上就是因為 Typescript 沒有爛尾。如今微軟在開源方向的發力是越來越顯著了,我很期待微軟接下來的表現,讓我們拭目以待。

變數型別和值型別

有的同學可能有疑問, JavaScript 不是也有型別麼? 它和 Typescript 的型別是一回事麼?JavaScript 不是動態語言麼,那麼經過 Typescript 的限定會不會喪失動態語言的動態性呢?我們繼續往下看。

  • JavaScript 中的型別其實是值的型別。實際上不僅僅是 JavaScript,任何動態型別語言都是如此,這也是動態型別語言的本質。
  • Typescript 中的型別其實是變數的型別。實際上不僅僅是 Typescript,任何靜態型別語言都是如此,這也是靜態型別語言的本質。

記住這兩句話,我們接下來解釋一下這兩句話。

對於 JavaScript 來說,一個變數可以是任意型別。

var a = 1;
a = "lucifer";
a = {};
a = [];

上面的值是有型別的。比如 1 是 number 型別,"lucifer" 是字串型別, {} 是物件型別, [] 是陣列型別。而變數 a 是沒有固定型別的。

對於 Typescript 來說, 一個變數只能接受和它型別相容的型別的值。說起來比較拗口, 看個例子就明白了。

var a: number = 1;
a = "lucifer"; // error
var b: any = 1;
a = "lucifer"; // ok
a = {}; // ok
a = []; // ok

我們不能將 string 型別的值賦值給變數 a, 因為 string 和 number 型別不相容。而我們可以將 string,Object,Array 型別的值賦值給 b,因此 它們和 any 型別相容。簡單來說就是,一旦一個變數被標註了某種型別,那麼其就只能接受這個型別以及它的子型別。

型別空間和值空間

型別和值居住在不同的空間,一個在陽間一個在陰間。他們之間互相不能訪問,甚至不知道彼此的存在。型別不能當做值來用,反之亦然。

型別空間

如下程式碼會報型別找不到的錯:

const aa: User = { name: "lucifer", age: 17 };

這個比較好理解,我們只需要使用 interface 宣告一下 User 就行。

interface User {
  name: string;
  age: number;
}

const aa: User = { name: "lucifer", age: 17 };

也就是說使用 interface 可以在型別空間宣告一個型別,這個是 Typescript 的型別檢查的基礎之一。

實際上型別空間內部也會有子空間。我們可以用 namespace(老)和 module(新) 來建立新的子空間。子空間之間不能直接接觸,需要依賴匯入匯出來互動。

值空間

比如,我用 Typescript 寫出如下的程式碼:

const a = window.lucifer();

Typescript 會報告一個類似Property 'lucifer' does not exist on type 'Window & typeof globalThis'. 的錯誤。

實際上,這種錯誤並不是型別錯誤,而是找不到成員變數的錯誤。我們可以這樣解決:

declare var lucifer: () => any;

也就是說使用 declare 可以在值空間宣告一個變數。這個是 Typescript 的變數檢查的基礎,不是本文要講的主要內容,大家知道就行。

明白了 JavaScript 和 TypeScript 型別的區別和聯絡之後,我們就可以來進入我們本文的主題了:型別系統

型別系統是 TypeScript 最主要的功能

TypeScript 官方描述中有一句:TypeScript adds optional types to JavaScript that support tools for large-scale JavaScript applications。實際上這也正是 Typescript 的主要功能,即給 JavaScript 新增靜態型別檢查。要想實現靜態型別檢查,首先就要有型別系統。總之,我們使用 Typescript 的主要目的仍然是要它的靜態型別檢查,幫助我們提供程式碼的擴充套件性和可維護性。因此 Typescript 需要維護一套完整的型別系統。

型別系統包括 1. 型別 和 2.對型別的使用和操作,我們先來看型別。

型別

TypeScript 支援 JavaScript 中所有的型別,並且還支援一些 JavaScript 中沒有的型別(畢竟是超集嘛)。沒有的型別可以直接提供,也可以提供自定義能力讓使用者來自己創造。 那為什麼要增加 JavaScript 中沒有的型別呢?我舉個例子,比如如下給一個變數宣告型別為 Object,Array 的程式碼。

const a: Object = {};
const b: Array = [];

其中:

  • 第一行程式碼 Typescript 允許,但是太寬泛了,我們很難得到有用的資訊,推薦的做法是使用 interface 來描述,這個後面會講到。
  • 第二行 Typescript 則會直接報錯,原因的本質也是太寬泛,我們需要使用泛型來進一步約束。

對型別的使用和操作

上面說了型別和值居住在不同的空間,一個在陽間一個在陰間。他們之間互相不能訪問,甚至不知道彼此的存在。

使用 declare 和 interface or type 就是分別在兩個空間程式設計。比如 Typescript 的泛型就是在型別空間程式設計,叫做型別程式設計。除了泛型,還有集合運算,一些操作符比如 keyof 等。值的程式設計在 Typescript 中更多的體現是在類似 lib.d.ts 這樣的庫。當然 lib.d.ts 也會在型別空間定義各種內建型別。我們沒有必要去死扣這個,只需要瞭解即可。

lib.d.ts 的內容主要是一些變數宣告(如:window、document、math)和一些類似的介面宣告(如:Window、Document、Math)。尋找程式碼型別(如:Math.floor)的最簡單方式是使用 IDE 的 F12(跳轉到定義)。

型別是如何做到靜態型別檢查的?

TypeScript 要想解決 JavaScript 動態語言型別太寬鬆的問題,就需要:

  1. 提供給變數設定型別的能力
注意是變數,不是值。
  1. 提供常用型別(不必須,但是沒有使用者體驗會極差)並可以擴充套件出自定義型別(必須)。
  2. 根據第一步給變數設定的型別進行型別檢查,即不允許型別不相容的賦值, 不允許使用值空間和型別空間不存在的變數和型別等。

第一個點是通過型別註解的語法來完成。即類似這樣:

const a: number = 1;
Typescript 的型別註解是這樣, Java 的型別註解是另一個樣子,Java 類似 int a = 1。 這個只是語法差異而已,作用是一樣的。

第二個問題, Typescript 提供了諸如 lib.d.ts 等型別庫檔案。隨著 ES 的不斷更新, JavaScript 型別和全域性變數會逐漸變多。Typescript 也是採用這種 lib 的方式來解決的。

(TypeScript 提供的部分 lib)

第三個問題,Typescript 主要是通過 interface,type,函式型別等打通型別空間,通過 declare 等打通值空間,並結合 binder 來進行型別診斷。關於 checker ,binder 是如何運作的,可以參考我第一篇的介紹。

接下來,我們介紹型別系統的功能,即它能為我們帶來什麼。如果上面的內容你已經懂了,那麼接下來的內容會讓你感到”你也不過如此嘛“。

型別系統的主要功能

  1. 定義型別以及其上的屬性和方法。

比如定義 String 型別, 以及其原型上的方法和屬性。

length, includes 以及 toString 是 String 的成員變數, 生活在值空間, 值空間雖然不能直接和型別空間接觸,但是型別空間可以作用在值空間,從而給其新增型別(如上圖黃色部分)。

  1. 提供自定義型別的能力
interface User {
  name: string;
  age: number;
  say(name: string): string;
}

這個是我自定義的型別 User,這是 Typescript 必須提供的能力。

  1. 型別相容體系。

這個主要是用來判斷型別是否正確的,上面我已經提過了,這裡就不贅述了。

  1. 型別推導

有時候你不需要顯式說明型別(型別註解),Typescript 也能知道他的型別,這就是型別推導結果。

const a = 1;

如上程式碼,編譯器會自動推匯出 a 的型別 為 number。還可以有連鎖推導,泛型的入參(泛型的入參是型別)推導等。型別推導還有一個特別有用的地方,就是用到型別收斂。

接下來我們詳細瞭解下型別推導和型別收斂。

型別推導和型別收斂

let a = 1;

如上程式碼。 Typescript 會推匯出 a 的型別為 number。

如果只會你這麼寫就會報錯:

a = "1";

因此 string 型別的值不能賦值給 number 型別的變數。我們可以使用 Typescript 內建的 typeof 關鍵字來證明一下。

let a = 1;
type A = typeof a;

此時 A 的型別就是 number,證明了變數 a 的型別確實被隱式推導成了 number 型別。

有意思的是如果 a 使用 const 宣告,那麼 a 不會被推導為 number,而是推導為型別 1。即值只能為 1 的型別,這就是型別收斂。

const a = 1;
type A = typeof a;
通過 const ,我們將 number 型別收縮到了 值只能為 1 的型別

實際情況的型別推導和型別收斂要遠比這個複雜, 但是做的事情都是一致的。

比如這個:

function test(a: number, b: number) {
  return a + b;
}
type A = ReturnType<typeof test>;

A 就是 number 型別。 也就是 Typescript 知道兩個 number 相加結果也是一個 number。因此即使你不顯示地註明返回值是 number, Typescript 也能猜到。這也是為什麼 JavaScript 專案不接入 Typescript 也可以獲得型別提示的原因之一

除了 const 可以收縮型別, typeof, instanceof 都也可以。 原因很簡單,就是Typescript 在這個時候可以 100% 確定你的型別了。 我來解釋一下:

比如上面的 const ,由於你是用 const 宣告的,因此 100% 不會變,一定永遠是 1,因此型別可以收縮為 1。 再比如:

let a: number | string = 1;
a = "1";
if (typeof a === "string") {
  a.includes;
}

if 語句內 a 100% 是 string ,不能是 number。因此 if 語句內型別會被收縮為 string。instanceof 也是類似,原理一模一樣。大家只要記住Typescript 如果可以 100% 確定你的型別,並且這個型別要比你定義的或者 Typescript 自動推導的範圍更小,那麼就會發生型別收縮就行了。

總結

本文主要講了 Typescript 的型別系統。 Typescript 和 JavaScript 的型別是很不一樣的。從表面上來看, TypeScript 的型別是 JavaScript 型別的超集。但是從更深層次上來說,兩者的本質是不一樣的,一個是值的型別,一個是變數的型別。

Typescript 空間分為值空間和型別空間。兩個空間不互通,因此值不能當成型別,型別不能當成值,並且值和型別不能做運算等。不過 TypeScript 可以將兩者結合起來用,這個能力只有 TypeScript 有, 作為 TypeScript 的開發者的你沒有這個能力,這個我在第一節也簡單介紹了。

TypeScript 既會對變數存在與否進行檢查,也會對變數型別進行相容檢查。因此 TypeScript 就需要定義一系列的型別,以及型別之間的相容關係。預設情況,TypeScript 是沒有任何型別和變數的,因此你使用 String 等都會報錯。TypeScript 使用庫檔案來解決這個問題,最經典的就是 lib.d.ts。

TypeScript 已經做到了足夠智慧了,以至於你不需要寫型別,它也能猜出來,這就是型別推導和型別收縮。當然 TypeScript 也有一些功能,我們覺得應該有,並且也是可以做到的功能空缺。但是我相信隨著 TypeScript 的逐步迭代(截止本文釋出,TypeScript 剛剛釋出了 4.0.0 的 beta 版本),一定會越來越完善,用著越來越舒服的。

我們每個專案的需要是不一樣的, 簡單的基本型別肯定無法滿足多樣的專案需求,因此我們必須支援自定義型別,比如 interface, type 以及複雜一點的泛型。當然泛型很大程度上是為了減少樣板程式碼而生的,和 interface , type 這種剛需不太一樣。

有了各種各樣的型別以及型別上的成員變數,以及成員變數的型別,再就加上型別的相容關係,我們就可以做型別檢查了,這就是 TypeScript 型別檢查的基礎。TypeScript 內部需要維護這樣的一個關係,並對變數進行型別繫結,從而給開發者提供型別分析服務。

關注我

大家也可以關注我的公眾號《腦洞前端》獲取更多更新鮮的前端硬核文章,帶你認識你不知道的前端。

公眾號【 力扣加加
知乎專欄【 Lucifer - 知乎

點關注,不迷路!

相關文章