結構化類型系統#
class Cat {
meow() { }
eat() { } // 去掉 eat 不會報錯
}
class Dog {
eat() { }
}
function feedCat(cat: Cat) { }
// 報錯!
feedCat(new Dog())
這是因為,TypeScript 比較兩個類型並非通過類型的名稱(即 feedCat
函數只能通過 Cat 類型調用),而是比較這兩個類型上實際擁有的屬性與方法。也就是說,這裡實際上是比較 Cat 類型上的屬性是否都存在於 Dog 類型上。
在我們最初的例子裡,Cat 與 Dog 類型上的方法是一致的,所以它們雖然是兩個名字不同的類型,但仍然被視為結構一致,這就是結構化類型系統的特性。你可能聽過結構類型的別稱鴨子類型(Duck Typing),這個名字來源於鴨子測試(Duck Test)。其核心理念是,如果你看到一隻鳥走起來像鴨子,游泳像鴨子,叫得也像鴨子,那麼這隻鳥就是鴨子。
class Cat {
eat() { }
}
class Dog {
bark() { }
eat() { }
}
function feedCat(cat: Cat) { }
feedCat(new Dog())
這個時候為什麼卻沒有類型報錯了?這是因為,結構化類型系統認為 Dog 類型完全實現了 Cat 類型。至於額外的方法 bark
,可以認為是 Dog 類型繼承 Cat 類型後添加的新方法,即此時 Dog 類可以被認為是 Cat 類的子類。同樣的,面向對象編程中的里氏替換原則也提到了鴨子測試:如果它看起來像鴨子,叫起來也像鴨子,但是卻需要電池才能工作,那麼你的抽象很可能出錯了。
更進一步,在比較對象類型的屬性時,同樣會採用結構化類型系統進行判斷。而對結構中的函數類型(即方法)進行比較時,同樣存在類型的兼容性比較:
class Cat {
eat(): boolean {
return true
}
}
class Dog {
eat(): number {
return 599;
}
}
function feedCat(cat: Cat) { }
// 報錯!
feedCat(new Dog())
標稱類型系統#
標稱類型系統(Nominal Typing System)要求,兩個可兼容的類型,其名稱必須是完全一致的,比如以下代碼:
type USD = number;
type CNY = number;
const CNYCount: CNY = 200;
const USDCount: USD = 200;
function addCNY(source: CNY, input: CNY) {
return source + input;
}
addCNY(CNYCount, USDCount)
在標稱類型系統中,CNY 與 USD 被認為是兩個完全不同的類型,因此能夠避免這一情況發生。在《編程與類型系統》一書中提到,類型的重要意義之一是限制了數據的可用操作與實際意義,這一點在標稱類型系統中的體現要更加明顯。比如,上面我們可以通過類型的結構,來讓結構化類型系統認為兩個類型具有父子類型關係,而對於標稱類型系統,父子類型關係只能通過顯式的繼承來實現,稱為標稱子類型(Nominal Subtyping)。
在 TypeScript 中模擬標稱類型系統#
export declare class TagProtector<T extends string> {
protected __tag__: T;
}
export type Nominal<T, U extends string> = T & TagProtector<U>;
在這裡我們使用 TagProtector 聲明了一個具有 protected
屬性的類,使用它來攜帶額外的信息,並和原本的類型合併到一起,就得到了 Nominal 工具類型。
export type CNY = Nominal<number, 'CNY'>;
export type USD = Nominal<number, 'USD'>;
const CNYCount = 100 as CNY;
const USDCount = 100 as USD;
function addCNY(source: CNY, input: CNY) {
return (source + input) as CNY;
}
addCNY(CNYCount, CNYCount);
// 報錯了!
addCNY(CNYCount, USDCount);
類型層級#
判斷類型兼容性的方式#
對於變量 a = 變量 b,如果成立,意味著 <變量 b 的類型> extends <變量 a 的類型>
成立,即 b 類型是 a 類型的子類型
type Result = 'linbudu' extends string ? 1 : 2;
declare let source: string;
declare let anyType: any;
declare let neverType: never;
anyType = source;
// 不能將類型“string”分配給類型“never”。
neverType = source;
基礎類型與字面量類型#
type Result1 = "linbudu" extends string ? 1 : 2; // 1
type Result2 = 1 extends number ? 1 : 2; // 1
type Result3 = true extends boolean ? 1 : 2; // 1
type Result4 = { name: string } extends object ? 1 : 2; // 1
type Result5 = { name: 'linbudu' } extends object ? 1 : 2; // 1
type Result6 = [] extends object ? 1 : 2; // 1
很明顯,一個基礎類型和它們對應的字面量類型必定存在父子類型關係。嚴格來說,object 出現在這裡並不恰當,因為它實際上代表著所有非原始類型的類型,即數組、對象與函數類型,所以這裡 Result6 成立的原因即是[]
這個字面量類型也可以被認為是 object 的字面量類型。我們將結論簡記為,字面量類型 < 對應的原始類型。
聯合類型#
type Result7 = 1 extends 1 | 2 | 3 ? 1 : 2; // 1
type Result8 = 'lin' extends 'lin' | 'bu' | 'du' ? 1 : 2; // 1
type Result9 = true extends true | false ? 1 : 2; // 1
type Result10 = string extends string | false | number ? 1 : 2; // 1
字面量類型 < 包含此字面量類型的聯合類型,原始類型 < 包含此原始類型的聯合類型
type Result11 = 'lin' | 'bu' | 'budu' extends string ? 1 : 2; // 1
type Result12 = {} | (() => void) | [] extends object ? 1 : 2; // 1
同一基礎類型的字面量聯合類型 < 此基礎類型
合併一下結論,去掉比較特殊的情況,我們得到了這個最終結論:字面量類型 < 包含此字面量類型的聯合類型(同一基礎類型) < 對應的原始類型,即:
// 2
type Result13 = 'linbudu' extends 'linbudu' | '599'
? 'linbudu' | '599' extends string
? 2
: 1
: 0;
裝箱類型#
type Result14 = string extends String ? 1 : 2; // 1
type Result15 = String extends {} ? 1 : 2; // 1
type Result16 = {} extends object ? 1 : 2; // 1
type Result18 = object extends Object ? 1 : 2; // 1
type Result16 = {} extends object ? 1 : 2; // 1
type Result18 = object extends {} ? 1 : 2; // 1
type Result17 = object extends Object ? 1 : 2; // 1
type Result20 = Object extends object ? 1 : 2; // 1
type Result19 = Object extends {} ? 1 : 2; // 1
type Result21 = {} extends Object ? 1 : 2; // 1
原始類型 < 原始類型對應的裝箱類型 < Object 類型
Top Type#
type Result22 = Object extends any ? 1 : 2; // 1
type Result23 = Object extends unknown ? 1 : 2; // 1
type Result24 = any extends Object ? 1 : 2; // 1 | 2
type Result25 = unknown extends Object ? 1 : 2; // 2
type Result26 = any extends 'linbudu' ? 1 : 2; // 1 | 2
type Result27 = any extends string ? 1 : 2; // 1 | 2
type Result28 = any extends {} ? 1 : 2; // 1 | 2
type Result29 = any extends never ? 1 : 2; // 1 | 2
在 TypeScript 內部代碼的條件類型處理中,如果接受判斷的是 any,那麼會直接返回條件類型結果組成的聯合類型。
type Result31 = any extends unknown ? 1 : 2; // 1
type Result32 = unknown extends any ? 1 : 2; // 1
Object < any / unknown
Bottom Type#
type Result33 = never extends 'linbudu' ? 1 : 2; // 1
type Result34 = undefined extends 'linbudu' ? 1 : 2; // 2
type Result35 = null extends 'linbudu' ? 1 : 2; // 2
type Result36 = void extends 'linbudu' ? 1 : 2; // 2
never < 字面量類型
其他比較場景#
- 對於基類和派生類,通常情況下派生類會完全保留基類的結構,而不只是自己新增新的屬性與方法。在結構化類型的比較下,其類型自然會存在子類型關係。更不用說派生類本身就是 extends 基類得到的。
- 對於聯合類型的類型層級比較,我們只需要比較一個聯合類型是否可被視為另一個聯合類型的子集,即這個聯合類型中所有成員在另一個聯合類型中都能找到。
type Result36 = 1 | 2 | 3 extends 1 | 2 | 3 | 4 ? 1 : 2; // 1
type Result37 = 2 | 4 extends 1 | 2 | 3 | 4 ? 1 : 2; // 1
type Result38 = 1 | 2 | 5 extends 1 | 2 | 3 | 4 ? 1 : 2; // 2
type Result39 = 1 | 5 extends 1 | 2 | 3 | 4 ? 1 : 2; // 2
- 數組和元組
type Result40 = [number, number] extends number[] ? 1 : 2; // 1
type Result41 = [number, string] extends number[] ? 1 : 2; // 2
type Result42 = [number, string] extends (number | string)[] ? 1 : 2; // 1
type Result43 = [] extends number[] ? 1 : 2; // 1
type Result44 = [] extends unknown[] ? 1 : 2; // 1
type Result45 = number[] extends (number | string)[] ? 1 : 2; // 1
type Result46 = any[] extends number[] ? 1 : 2; // 1
type Result47 = unknown[] extends number[] ? 1 : 2; // 2
type Result48 = never[] extends number[] ? 1 : 2; // 1
References:
https://juejin.cn/book/7086408430491172901