TypeScript 中的型別相容性基於結構子型別(structural subtyping)。結構型別系統是一種僅根據成員來關聯型別的方式。這與名義型別系統(nominal typing)形成對比。請看以下程式碼:
tsinterface Pet {name: string;}class Dog {name: string;}let pet: Pet;// OK, because of structural typingpet = new Dog();
在諸如 C# 或 Java 等名義型別語言中,上述程式碼會報錯,因為 Dog 類沒有顯式宣告它實現了 Pet 介面。
TypeScript 的結構型別系統是根據 JavaScript 的典型編寫方式設計的。由於 JavaScript 廣泛使用函式表示式和物件字面量等匿名物件,使用結構型別系統來表示 JavaScript 庫中的關係比使用名義型別系統要自然得多。
關於穩健性(Soundness)的說明
TypeScript 的型別系統允許某些無法在編譯時確保安全的操作。當型別系統具有這一特性時,我們稱其為“不穩健”(unsound)。TypeScript 允許不穩健行為的地方都是經過深思熟慮的,在本文件中,我們將解釋這些情況發生的位置及其背後的動機場景。
入門
TypeScript 結構型別系統的基本規則是:如果 y 至少具有與 x 相同的成員,則 x 與 y 相容。例如,考慮以下涉及名為 Pet 的介面的程式碼,該介面具有 name 屬性:
tsinterface Pet {name: string;}let pet: Pet;// dog's inferred type is { name: string; owner: string; }let dog = { name: "Lassie", owner: "Rudd Weatherwax" };pet = dog;
為了檢查 dog 是否可以賦值給 pet,編譯器會檢查 pet 的每個屬性,以查詢 dog 中對應的相容屬性。在這種情況下,dog 必須具有一個名為 name 的字串型別的成員。它確實有,因此賦值是允許的。
在檢查函式呼叫引數時,也會使用相同的賦值規則:
tsinterface Pet {name: string;}let dog = { name: "Lassie", owner: "Rudd Weatherwax" };function greet(pet: Pet) {console.log("Hello, " + pet.name);}greet(dog); // OK
請注意,dog 還有一個額外的 owner 屬性,但這並不會導致錯誤。在檢查相容性時,僅考慮目標型別(本例中為 Pet)的成員。此比較過程是遞迴進行的,會探索每個成員和子成員的型別。
然而需要注意,物件字面量只能指定已知的屬性。例如,因為我們明確指定了 dog 是 Pet 型別,所以以下程式碼是無效的:
tslet dog: Pet = { name: "Lassie", owner: "Rudd Weatherwax" }; // Error
比較兩個函式
雖然比較原始型別和物件型別相對直觀,但什麼樣的函式應該被視為相容,這個問題就稍微複雜一些。讓我們從兩個僅在引數列表上不同的函式的基本示例開始:
tslet x = (a: number) => 0;let y = (b: number, s: string) => 0;y = x; // OKx = y; // Error
為了檢查 x 是否可以賦值給 y,我們首先檢視引數列表。x 中的每個引數都必須在 y 中有一個對應且型別相容的引數。請注意,不考慮引數名稱,只考慮它們的型別。在這種情況下,x 的每個引數在 y 中都有一個對應的相容引數,因此賦值是允許的。
第二次賦值會報錯,因為 y 有一個 x 所沒有的必需的第二個引數,因此賦值是不允許的。
你可能想知道為什麼我們允許像 y = x 示例中那樣“丟棄”引數。允許此賦值的原因是,忽略額外的函式引數在 JavaScript 中非常普遍。例如,Array#forEach 為回撥函式提供了三個引數:陣列元素、其索引和包含該陣列的陣列本身。然而,提供一個僅使用第一個引數的回撥函式是非常有用的:
tslet items = [1, 2, 3];// Don't force these extra parametersitems.forEach((item, index, array) => console.log(item));// Should be OK!items.forEach((item) => console.log(item));
現在讓我們看看如何處理返回型別,使用兩個僅在返回型別上不同的函式:
tslet x = () => ({ name: "Alice" });let y = () => ({ name: "Alice", location: "Seattle" });x = y; // OKy = x; // Error, because x() lacks a location property
型別系統強制要求源函式的返回型別必須是目標函式返回型別的子型別。
函式引數雙向協變(Bivariance)
當比較函式引數型別時,如果源引數可賦值給目標引數,或者反之亦然,賦值就會成功。這在型別上是不穩健的,因為呼叫者可能會獲得一個接受更具體型別的函式,但卻用不那麼具體的型別來呼叫它。在實踐中,這類錯誤很少見,且允許這樣做可以支援許多常見的 JavaScript 模式。簡單舉例:
tsenum EventType {Mouse,Keyboard,}interface Event {timestamp: number;}interface MyMouseEvent extends Event {x: number;y: number;}interface MyKeyEvent extends Event {keyCode: number;}function listenEvent(eventType: EventType, handler: (n: Event) => void) {/* ... */}// Unsound, but useful and commonlistenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));// Undesirable alternatives in presence of soundnesslistenEvent(EventType.Mouse, (e: Event) =>console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y));listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>console.log(e.x + "," + e.y)) as (e: Event) => void);// Still disallowed (clear error). Type safety enforced for wholly incompatible typeslistenEvent(EventType.Mouse, (e: number) => console.log(e));
當這種情況發生時,你可以透過編譯器標誌 strictFunctionTypes 讓 TypeScript 丟擲錯誤。
可選引數和剩餘引數
在比較函式相容性時,可選引數和必需引數是可以互換的。源型別中額外的可選引數不會導致錯誤,目標型別中沒有在源型別中找到對應引數的可選引數也不會導致錯誤。
當函式具有剩餘引數(rest parameter)時,它會被視為無限的一系列可選引數。
從型別系統的角度來看,這並不穩健,但從執行時角度來看,可選引數的概念通常沒有被很好地強制執行,因為對於大多數函式而言,在那個位置傳遞 undefined 是等效的。
典型的例子是那種接受回撥函式並以程式設計師可預測(但型別系統未知)的數量引數呼叫它的常見模式:
tsfunction invokeLater(args: any[], callback: (...args: any[]) => void) {/* ... Invoke callback with 'args' ... */}// Unsound - invokeLater "might" provide any number of argumentsinvokeLater([1, 2], (x, y) => console.log(x + ", " + y));// Confusing (x and y are actually required) and undiscoverableinvokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));
具有過載的函式
當函式具有過載時,目標型別中的每個過載都必須由源型別中的相容簽名匹配。這確保了源函式可以在目標函式可以呼叫的所有情況下被呼叫。
列舉(Enums)
列舉與數字相容,數字也與列舉相容。來自不同列舉型別的列舉值被認為是不相容的。例如:
tsenum Status {Ready,Waiting,}enum Color {Red,Blue,Green,}let status = Status.Ready;status = Color.Green; // Error
類 (Classes)
類的行為類似於物件字面量型別和介面,但有一個例外:它們既有靜態型別,也有例項型別。當比較兩個類型別的物件時,僅比較例項成員。靜態成員和建構函式不影響相容性。
tsclass Animal {feet: number;constructor(name: string, numFeet: number) {}}class Size {feet: number;constructor(numFeet: number) {}}let a: Animal;let s: Size;a = s; // OKs = a; // OK
類中的私有和受保護成員
類中的私有(private)和受保護(protected)成員會影響其相容性。當檢查類例項的相容性時,如果目標型別包含私有成員,則源型別也必須包含一個源自同一類的私有成員。同樣,對於包含受保護成員的例項,規則也一樣。這允許類與其超類賦值相容,但不允許與具有相同形狀但屬於不同繼承層次結構的類相容。
泛型
因為 TypeScript 是結構型別系統,型別引數僅在作為成員型別的一部分被消耗時才影響結果型別。例如:
tsinterface Empty<T> {}let x: Empty<number>;let y: Empty<string>;x = y; // OK, because y matches structure of x
在上面的例子中,x 和 y 是相容的,因為它們的結構沒有以區分的方式使用型別引數。透過給 Empty<T> 新增成員來修改這個示例,可以看到它是如何工作的:
tsinterface NotEmpty<T> {data: T;}let x: NotEmpty<number>;let y: NotEmpty<string>;x = y; // Error, because x and y are not compatible
透過這種方式,指定了型別引數的泛型型別表現得就像非泛型型別一樣。
對於未指定型別引數的泛型型別,透過用 any 替換所有未指定的型別引數來檢查相容性。然後按照非泛型情況下的方式檢查結果型別的相容性。
例如:
tslet identity = function <T>(x: T): T {// ...};let reverse = function <U>(y: U): U {// ...};identity = reverse; // OK, because (x: any) => any matches (y: any) => any
進階主題
子型別 vs 賦值
到目前為止,我們一直使用“相容”一詞,但語言規範中並沒有定義這個詞。在 TypeScript 中,有兩種相容性:子型別(subtype)和賦值(assignment)。它們的不同之處僅在於賦值擴充套件了子型別相容性,增加了允許 any 之間以及與 enum 及對應數值之間進行賦值的規則。
根據具體情況,語言中的不同位置使用這兩種相容性機制之一。實際上,型別相容性通常由賦值相容性決定,即便是對於 implements 和 extends 子句也是如此。
any、unknown、object、void、undefined、null 和 never 的賦值性
下表總結了某些抽象型別之間的賦值性。行表示每個型別可以賦值給什麼,列表示什麼可以賦值給它們。 ”✓” 表示僅當 strictNullChecks 關閉時才相容的組合。
| any | unknown | object | void | undefined | null | never | |
|---|---|---|---|---|---|---|---|
| any → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
| unknown → | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | |
| object → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
| void → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
| undefined → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
| null → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
| never → | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
重申基礎知識
- 所有型別都可以賦值給自己。
any和unknown在可賦值給它們的內容方面是相同的,區別在於unknown除了any之外不能賦值給任何東西。unknown和never互為逆元。所有型別都可以賦值給unknown,never可以賦值給所有型別。沒有什麼可以賦值給never,unknown不能賦值給任何東西(除了any)。void不能賦值給其他任何東西,也不能接受其他任何東西的賦值,以下情況除外:any、unknown、never、undefined和null(如果strictNullChecks已關閉,詳情參見表格)。- 當
strictNullChecks關閉時,null和undefined類似於never:它們可以賦值給大多數型別,但大多數型別不能賦值給它們。它們彼此之間可以相互賦值。 - 當
strictNullChecks開啟時,null和undefined的表現更像void:不能賦值給其他任何東西,也不能接受其他任何東西的賦值,除了any、unknown和void(undefined總是可以賦值給void)。