類型推導就是在沒有明確指出類型的地方,TypeScript編譯器會自己去推測出當前變量的類型。
例如下面的例子:
let a = 1;
我們並沒有明確指明a的類型,所以編譯器通過結果反向推斷變量a的類型為number,這種推斷發生在初始化變量和成員,設置默認參數值和函數有返回值時。
大多數情況下,類型推導是直截了當的,但也有很復雜的情況,例如需要去匹配參數來推測類型。
最佳通用類型
當需要從幾個表達式中推斷類型時候,會使用這些表達式的類型來推斷出一個最合適的通用類型。例如,
let x = [0, 'fanqi', null]; //(string | number)[]
為了推斷x
的類型,我們必須考慮所有元素的類型。 這里有三種選擇: number、string
和null
。 計算通用類型算法會考慮所有的候選類型,並給出一個兼容所有候選類型的類型。這個例子就是(string | number)[]。
由於最終的通用類型取自候選類型,有些時候候選類型共享相同的通用類型,但是卻沒有一個類型能做為所有候選類型的類型。例如:
let zoo = [new Rhino(), new Elephant(), new Snake()];
這里,我們想讓zoo被推斷為Animal[]
類型,但是這個數組里沒有對象是Animal
類型的,因此不能推斷出這個結果。 為了更正,當候選類型不能使用的時候我們需要明確的指出類型:
let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];
如果沒有找到最佳通用類型的話,類型推斷的結果為聯合數組類型,(Rhino | Elephant | Snake)[]
。
上下文類型
TypeScript類型推論也可能按照相反的方向進行。 這被叫做“按上下文歸類”。按上下文歸類會發生在表達式的類型與所處的位置相關時。比如:
window.onmousedown = function(mouseEvent) { console.log(mouseEvent.button); //<- Error };
這個例子會得到一個類型錯誤,TypeScript類型檢查器使用Window.onmousedown
函數的類型來推斷右邊函數表達式的類型。 因此,就能推斷出 mouseEvent
參數的類型了。 如果函數表達式不是在上下文類型的位置, mouseEvent
參數的類型需要指定為any
,這樣也不會報錯了。
如果上下文類型表達式包含了明確的類型信息,上下文的類型被忽略。 重寫上面的例子:
window.onmousedown = function(mouseEvent: any) { console.log(mouseEvent.button); //<- Now, no error is given };
這個函數表達式有明確的參數類型注解,上下文類型被忽略。 這樣的話就不報錯了,因為這里不會使用到上下文類型。
上下文歸類會在很多情況下使用到。 通常包含函數的參數,賦值表達式的右邊,類型斷言,對象成員和數組字面量和返回值語句。 上下文類型也會做為最佳通用類型的候選類型。比如:
function createZoo(): Animal[] { return [new Rhino(), new Elephant(), new Snake()]; }
這個例子里,最佳通用類型有4個候選者:Animal
,Rhino
,Elephant
和Snake
。 當然, Animal
會被做為最佳通用類型。
類型兼容性
TypeScript里的類型兼容性是基於結構子類型的。 結構類型是一種只使用其成員來描述類型的方式。 它正好與名義(nominal)類型形成對比。(譯者注:在基於名義類型的類型系統中,數據類型的兼容性或等價性是通過明確的聲明和/或類型的名稱來決定的。這與結構性類型系統不同,它是基於類型的組成結構,且不要求明確地聲明。) 看下面的例子:
interface Person { name: string; } class Father { name: string; } let person: Person; // OK, because of structural typing person = new Father();
在使用基於名義類型的語言,例如C#或Java中,這段代碼會報錯,因為Father類並沒有明確說明其實現了Person接口。
TypeScript的結構性子類型是根據JavaScript代碼的典型寫法來設計的。 因為JavaScript里廣泛地使用匿名對象,例如函數表達式和對象字面量,所以使用結構類型系統來描述這些類型比使用名義類型系統更好。
關於可靠性的注意事項
我們可以看到在以上的類型中,只要滿足了子結構的描述,那么它就可以通過編譯時檢查,所以TypeScript的設計思想並不是滿足正確的類型,而是滿足能正確通過編譯的類型,這就造成了運行時和編譯時可能存在類型偏差。
所以TypeScript的類型系統允許某些在編譯時無法確認其安全性的操作。當一個類型系統具有此屬性時,被認為是“不可靠”的。而TypeScript允許這種不可靠行為的發生是經過仔細考慮的。下面我們會解釋為什么需要這種特性。
開始
TypeScript結構化類型系統的基本規則是,如果x
要兼容y
,那么y
至少具有與x
相同的屬性。例如:
interface Person { name: string; } let person: Person; let y = { name: 'fanqi', age: 25}; person = y;
當將y賦值給person時,編譯器會檢查person中的每個屬性,看是否能在y中也找到對應的屬性。 在這個例子中,編譯器發現y中也含有name屬性,那賦值就是正確的,即使事實上並不准確。
檢查函數參數時使用相同的規則:
function greetTo(person: Person) { console.log('Hello, ' + person.name); } greetTo(y); // OK
y
有個額外的age
屬性,但這不會引發錯誤。 因為TypeScript只會檢查是否符合Peson的類型標准。
這個比較過程是遞歸進行的,檢查每個成員及子成員。
比較兩個函數
相對來講,在比較原始類型和對象類型的時候是比較容易理解的,而在判斷兩個函數返回值是否相等時,TypeScript比對的是函數簽名。
一個函數里面包含了參數及返回值,我們可以看一看下面這個例子:
let x = (a: number) => 0; let y = (b: number, s: string) => 0; y = x; // OK x = y; // Error
要查看x
是否能賦值給y
,首先看它們的參數列表。 x
的每個參數必須能在y
里找到對應類型的參數。 注意,參數的名字相同與否無所謂,只看它們的類型。 這里,x
的每個參數在y
中都能找到對應的參數,所以賦值是允許的。
x=y會引發賦值錯誤,因為y
有第二個必填參數,但是x
並沒有,所以不允許賦值。
你可能會疑惑,為什么允許x忽略
參數,像例子y = x
中那樣。 原因是忽略額外的參數在JavaScript里是很常見的。 例如,Array.map和Array.forEach,並不要求用到每個參數。
let items = [1, 2, 3]; // Don't force these extra arguments items.forEach((item, index, array) => console.log(item)); // Should be OK! items.forEach((item) => console.log(item));
下面來看看如何處理返回值類型,創建兩個僅是返回值類型不同的函數:
let x = () => ({name: 'Alice'}); let y = () => ({name: 'Alice', location: 'Seattle'}); x = y; // OK y = x; // Error, because x() lacks a location property
類型系統強制源函數的返回值類型必須是目標函數返回值類型的子類型。
我們再來看一個更復雜的情況,叫ReturnType,這個類型也是寫在typescript/lib/lib.es5.d.ts中
/** * Obtain the return type of a function type */ type ReturnType<T extends (...args: any[]) => any> = T extends (...args:any[]) => infer R ? R : any;
ReturnType的使用是這樣的:
let x = (a:number) => ({a,b:'hello'}); type xReturnType = ReturnType<typeof x> // type xReturnType = { // a:number; // b:string; // }
infer關鍵字可以幫助我們引入一個待推斷的類型變量,這個待推斷的類型變量在推斷成立時會寫入類型,而在失敗時會回退為any。
到目前為止,我們簡單的梳理了一遍TypeScript的類型推導,也初步了解了infer的使用。了解類型推導主要是為了使我們能動態的獲取類型,減少手動標注類型的工作量,提升效率。
函數參數雙向協變
當比較函數參數類型時,只有當源函數參數能夠賦值給目標函數或者反過來時才能賦值成功。 這是不穩定的,因為調用者可能傳入了一個具有更精確類型信息的函數,但是調用這個傳入的函數的時候卻使用了不是那么精確的類型信息。 實際上,這極少會發生錯誤,並且能夠實現很多JavaScript里的常見模式。例如:
enum EventType { Mouse, Keyboard } interface Event { timestamp: number; } interface MouseEvent extends Event { x: number; y: number } interface KeyEvent extends Event { keyCode: number } function listenEvent(eventType: EventType, handler: (n: Event) => void) { /* ... */ } // Unsound, but useful and common listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y)); // Undesirable alternatives in presence of soundness listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + ',' + (<MouseEvent>e).y)); listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + ',' + e.y))); // Still disallowed (clear error). Type safety enforced for wholly incompatible types listenEvent(EventType.Mouse, (e: number) => console.log(e));
可選參數及剩余參數
比較函數兼容性的時候,可選參數與必須參數是可互換的。 源類型上有額外的可選參數不是錯誤,目標類型的可選參數在源類型里沒有對應的參數也不是錯誤。
當一個函數有剩余參數時,它被當做無限個可選參數。
這對於類型系統來說是不穩定的,但從運行時的角度來看,可選參數一般來說是不強制的,因為對於大多數函數來說相當於傳遞了一些undefinded
。
有一個好的例子,常見的函數接收一個回調函數並用對於程序員來說是可預知的參數但對類型系統來說是不確定的參數來調用:
function invokeLater(args: any[], callback: (...args: any[]) => void) { /* ... Invoke callback with 'args' ... */ } // Unsound - invokeLater "might" provide any number of arguments invokeLater([1, 2], (x, y) => console.log(x + ', ' + y)); // Confusing (x and y are actually required) and undiscoverable invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));
函數重載
對於有重載的函數,源函數的每個重載都要在目標函數上找到對應的函數簽名。 這確保了目標函數可以在所有源函數可調用的地方調用。
枚舉
枚舉類型與數字類型兼容,並且數字類型與枚舉類型兼容。不同枚舉類型之間是不兼容的。比如,
enum Status { Ready, Waiting }; enum Color { Red, Blue, Green }; let status = Status.Ready; status = Color.Green; // Error
類
類與對象字面量和接口差不多,但有一點不同:類有靜態部分和實例部分的類型。 比較兩個類類型的對象時,只有實例的成員會被比較。 靜態成員和構造函數不在比較的范圍內。
class Animal { feet: number; constructor(name: string, numFeet: number) { } } class Size { feet: number; constructor(numFeet: number) { } } let a: Animal; let s: Size; a = s; // OK s = a; // OK
類的私有成員和受保護成員
類的私有成員和受保護成員會影響兼容性。 當檢查類實例的兼容時,如果目標類型包含一個私有成員,那么源類型必須包含來自同一個類的這個私有成員。 同樣地,這條規則也適用於包含受保護成員實例的類型檢查。 這允許子類賦值給父類,但是不能賦值給其它有同樣類型的類。
泛型
因為TypeScript是結構性的類型系統,類型參數只影響使用其做為類型一部分的結果類型。比如,
interface Empty<T> { } let x: Empty<number>; let y: Empty<string>; x = y; // OK, because y matches structure of x
上面代碼里,x
和y
是兼容的,因為它們的結構使用類型參數時並沒有什么不同。 把這個例子改變一下,增加一個成員,就能看出是如何工作的了:
interface NotEmpty<T> { data: T; } let x: NotEmpty<number>; let y: NotEmpty<string>; x = y; // Error, because x and y are not compatible
在這里,泛型類型在使用時就好比不是一個泛型類型。
對於沒指定泛型類型的泛型參數時,會把所有泛型參數當成any
比較。 然后用結果類型進行比較,就像上面第一個例子。
比如,
let identity = function<T>(x: T): T { // ... } let reverse = function<U>(y: U): U { // ... } identity = reverse; // OK, because (x: any) => any matches (y: any) => any
高級主題
子類型與賦值
目前為止,我們使用了“兼容性”,它在語言規范里沒有定義。 在TypeScript里,有兩種兼容性:子類型和賦值。 它們的不同點在於,賦值擴展了子類型兼容性,增加了一些規則,允許和any
來回賦值,以及enum
和對應數字值之間的來回賦值。
語言里的不同地方分別使用了它們之中的機制。 實際上,類型兼容性是由賦值兼容性來控制的,即使在implements
和extends
語句也不例外。