TypeScript入門——型別斷言
型別斷言(Type Assertion)可以用來手動指定一個值的型別。
語法
值 as 型別
或
<型別>值
在 tsx 語法(React 的 jsx 語法的 ts 版)中必須使用前者,即 值 as 型別
。
形如 <Foo>
的語法在 tsx 中表示的是一個 ReactNode
,在 ts 中除了表示型別斷言之外,也可能是表示一個泛型。
故建議大家在使用型別斷言時,統一使用 值 as 型別
這樣的語法,本書中也會貫徹這一思想。
型別斷言的用途
型別斷言的常見用途有以下幾種:
將一個聯合型別斷言為其中一個型別
當 TypeScript 不確定一個聯合型別的變數到底是哪個型別的時候,我們只能訪問此聯合型別的所有型別中共有的屬性或方法
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function getName(animal: Cat | Fish) {
return animal.name;
}
而有時候,我們確實需要在還不確定型別的時候就訪問其中一個型別特有的屬性或方法,比如:
interface Cat { name: string; run(): void; } interface Fish { name: string; swim(): void; } function isFish(animal: Cat | Fish) { return typeof animal.swim === 'function'; }
index.ts:14:26 - error TS2339: Property 'swim' does not exist on type 'Cat | Fish'.
Property 'swim' does not exist on type 'Cat'.
上面的例子中,獲取 animal.swim
的時候會報錯。
此時可以使用型別斷言,將 animal
斷言成 Fish
:
interface Cat { name: string; run(): void; } interface Fish { name: string; swim(): void; } function isFish(animal: Cat | Fish) { return typeof (animal as Fish).swim === 'function'; }
function isFish(animal) {
return typeof animal.swim === 'function';
}
這樣就可以解決訪問 animal.swim
時報錯的問題了。
需要注意的是,型別斷言只能夠「欺騙」TypeScript 編譯器,無法避免執行時的錯誤,反而濫用型別斷言可能會導致執行時錯誤:
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function swim(animal: Cat | Fish) {
(animal as Fish).swim();
}
const tom: Cat = {
name: 'Tom',
run() {
console.log('run')
}
};
swim(tom);
function swim(animal) {
animal.swim();
}
var tom = {
name: 'Tom',
run: function () {
console.log('run');
}
};
swim(tom);
// TypeError: animal.swim is not a function
原因是 (animal as Fish).swim()
這段程式碼隱藏了 animal
可能為 Cat
的情況,將 animal
直接斷言為 Fish
了,而 TypeScript 編譯器信任了我們的斷言,故在呼叫 swim()
時沒有編譯錯誤。
可是 swim
函式接受的引數是 Cat | Fish
,一旦傳入的引數是 Cat
型別的變數,由於 Cat
上沒有 swim
方法,就會導致執行時錯誤了。
總之,使用型別斷言時一定要格外小心,儘量避免斷言後呼叫方法或引用深層屬性,以減少不必要的執行時錯誤。
將一個父類斷言為更加具體的子類
當類之間有繼承關係時,型別斷言也是很常見的:
class ApiError extends Error {
code: number = 0;
}
class HttpError extends Error {
statusCode: number = 200;
}
function isApiError(error: Error) {
return typeof (error as ApiError).code === 'number';
}
上面的例子中,我們聲明瞭函式 isApiError
,它用來判斷傳入的引數是不是 ApiError
型別,為了實現這樣一個函式,它的引數的型別肯定得是比較抽象的父類 Error
,這樣的話這個函式就能接受 Error
或它的子類作為引數了。
但是由於父類 Error
中沒有 code
屬性,故直接獲取 error.code
會報錯,需要使用型別斷言獲取 (error as ApiError).code
。
大家可能會注意到,在這個例子中有一個更合適的方式來判斷是不是 ApiError
,那就是使用 instanceof
:
class ApiError extends Error {
code: number = 0;
}
class HttpError extends Error {
statusCode: number = 200;
}
function isApiError(error: Error) {
return error instanceof ApiError;
}
上面的例子中,確實使用 instanceof
更加合適,因為 ApiError
是一個 JavaScript 的類,能夠通過 instanceof
來判斷 error
是否是它的例項。
但是有的情況下 ApiError
和 HttpError
不是一個真正的類,而只是一個 TypeScript 的介面(interface
),介面是一個型別,不是一個真正的值,它在編譯結果中會被刪除,當然就無法使用 instanceof
來做執行時判斷了:
interface ApiError extends Error {
code: number;
}
interface HttpError extends Error {
statusCode: number;
}
function isApiError(error: Error) {
return error instanceof ApiError;
}
// index.ts:10:29 - error TS2693: 'ApiError' only refers to a type, but is being used as a value here.
此時就只能用型別斷言,通過判斷是否存在 code
屬性,來判斷傳入的引數是不是 ApiError
了:
interface ApiError extends Error {
code: number;
}
interface HttpError extends Error {
statusCode: number;
}
function isApiError(error: Error) {
return typeof (error as ApiError).code === 'number';
}
將任何一個型別斷言為 any
理想情況下,TypeScript 的型別系統運轉良好,每個值的型別都具體而精確。
當我們引用一個在此型別上不存在的屬性或方法時,就會報錯:
const foo: number = 1;
foo.length = 1;
// index.ts:2:5 - error TS2339: Property 'length' does not exist on type 'number'.
上面的例子中,數字型別的變數 foo
上是沒有 length
屬性的,故 TypeScript 給出了相應的錯誤提示。
這種錯誤提示顯然是非常有用的。
但有的時候,我們非常確定這段程式碼不會出錯,比如下面這個例子:
window.foo = 1;
// index.ts:1:8 - error TS2339: Property 'foo' does not exist on type 'Window & typeof globalThis'.
上面的例子中,我們需要將 window
上新增一個屬性 foo
,但 TypeScript 編譯時會報錯,提示我們 window
上不存在 foo
屬性。
此時我們可以使用 as any
臨時將 window
斷言為 any
型別:
(window as any).foo = 1;
在 any
型別的變數上,訪問任何屬性都是允許的。
需要注意的是,將一個變數斷言為 any
可以說是解決 TypeScript 中型別問題的最後一個手段。
它極有可能掩蓋了真正的型別錯誤,所以如果不是非常確定,就不要使用 as any
。
上面的例子中,我們也可以通過[擴充套件 window 的型別(TODO)][]解決這個錯誤,不過如果只是臨時的增加 foo
屬性,as any
會更加方便。
總之,一方面不能濫用 as any
,另一方面也不要完全否定它的作用,我們需要在型別的嚴格性和開發的便利性之間掌握平衡(這也是 TypeScript 的設計理念之一),才能發揮出 TypeScript 最大的價值。
將 any
斷言為一個具體的型別
在日常的開發中,我們不可避免的需要處理 any
型別的變數,它們可能是由於第三方庫未能定義好自己的型別,也有可能是歷史遺留的或其他人編寫的爛程式碼,還可能是受到 TypeScript 型別系統的限制而無法精確定義型別的場景。
遇到 any
型別的變數時,我們可以選擇無視它,任由它滋生更多的 any
。
我們也可以選擇改進它,通過型別斷言及時的把 any
斷言為精確的型別,亡羊補牢,使我們的程式碼向著高可維護性的目標發展。
舉例來說,歷史遺留的程式碼中有個 getCacheData
,它的返回值是 any
:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
那麼我們在使用它時,最好能夠將呼叫了它之後的返回值斷言成一個精確的型別,這樣就方便了後續的操作:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom = getCacheData('tom') as Cat;
tom.run();
上面的例子中,我們呼叫完 getCacheData
之後,立即將它斷言為 Cat
型別。這樣的話明確了 tom
的型別,後續對 tom
的訪問時就有了程式碼補全,提高了程式碼的可維護性。
型別斷言的限制
從上面的例子中,我們可以總結出:
- 聯合型別可以被斷言為其中一個型別
- 父類可以被斷言為子類
- 任何型別都可以被斷言為 any
- any 可以被斷言為任何型別
那麼型別斷言有沒有什麼限制呢?是不是任何一個型別都可以被斷言為任何另一個型別呢?
答案是否定的——並不是任何一個型別都可以被斷言為任何另一個型別。
具體來說,若 A
相容 B
,那麼 A
能夠被斷言為 B
,B
也能被斷言為 A
。
下面我們通過一個簡化的例子,來理解型別斷言的限制:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
let tom: Cat = {
name: 'Tom',
run: () => {
console.log('run')
}
};
let animal: Animal = tom;
我們知道,TypeScript 是結構型別系統,型別之間的對比只會比較它們最終的結構,而會忽略它們定義時的關係。
在上面的例子中,Cat
包含了 Animal
中的所有屬性,除此之外,它還有一個額外的方法 run
。TypeScript 並不關心 Cat
和 Animal
之間定義時是什麼關係,而只會看它們最終的結構有什麼關係——所以它與 Cat extends Animal
是等價的:
interface Animal {
name: string;
}
interface Cat extends Animal {
run(): void;
}
那麼也不難理解為什麼 Cat
型別的 tom
可以賦值給 Animal
型別的 animal
了——就像面向物件程式設計中我們可以將子類的例項賦值給型別為父類的變數。
我們把它換成 TypeScript 中更專業的說法,即:Animal
相容 Cat
。
當 Animal
相容 Cat
時,它們就可以互相進行型別斷言了:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
function testAnimal(animal: Animal) {
return (animal as Cat);
}
function testCat(cat: Cat) {
return (cat as Animal);
}
這樣的設計其實也很容易就能理解:
- 允許
animal as Cat
是因為「父類可以被斷言為子類」,這個前面已經學習過了 - 允許
cat as Animal
是因為既然子類擁有父類的屬性和方法,那麼被斷言為父類,獲取父類的屬性、呼叫父類的方法,就不會有任何問題,故「子類可以被斷言為父類」
需要注意的是,這裡我們使用了簡化的父類子類的關係來表達型別的相容性,而實際上 TypeScript 在判斷型別的相容性時,比這種情況複雜很多,詳細請參考[型別的相容性(TODO)][]章節。
總之,若 A
相容 B
,那麼 A
能夠被斷言為 B
,B
也能被斷言為 A
。
同理,若 B
相容 A
,那麼 A
能夠被斷言為 B
,B
也能被斷言為 A
。
所以這也可以換一種說法:
要使得 A
能夠被斷言為 B
,只需要 A
相容 B
或 B
相容 A
即可,這也是為了在型別斷言時的安全考慮,畢竟毫無根據的斷言是非常危險的。
綜上所述:
- 聯合型別可以被斷言為其中一個型別
- 父類可以被斷言為子類
- 任何型別都可以被斷言為 any
- any 可以被斷言為任何型別
- 要使得
A
能夠被斷言為B
,只需要A
相容B
或B
相容A
即可
其實前四種情況都是最後一個的特例。
雙重斷言
既然:
- 任何型別都可以被斷言為 any
- any 可以被斷言為任何型別
那麼我們是不是可以使用雙重斷言 as any as Foo
來將任何一個型別斷言為任何另一個型別呢?
interface Cat {
run(): void;
}
interface Fish {
swim(): void;
}
function testCat(cat: Cat) {
return (cat as any as Fish);
}
在上面的例子中,若直接使用 cat as Fish
肯定會報錯,因為 Cat
和 Fish
互相都不相容。
但是若使用雙重斷言,則可以打破「要使得 A
能夠被斷言為 B
,只需要 A
相容 B
或 B
相容 A
即可」的限制,將任何一個型別斷言為任何另一個型別。
若你使用了這種雙重斷言,那麼十有八九是非常錯誤的,它很可能會導致執行時錯誤。
除非迫不得已,千萬別用雙重斷言。
型別斷言 vs 型別轉換
型別斷言只會影響 TypeScript 編譯時的型別,型別斷言語句在編譯結果中會被刪除:
function toBoolean(something: any): boolean {
return something as boolean;
}
toBoolean(1);
// 返回值為 1
在上面的例子中,將 something
斷言為 boolean
雖然可以通過編譯,但是並沒有什麼用,程式碼在編譯後會變成:
function toBoolean(something) {
return something;
}
toBoolean(1);
// 返回值為 1
所以型別斷言不是型別轉換,它不會真的影響到變數的型別。
若要進行型別轉換,需要直接呼叫型別轉換的方法:
function toBoolean(something: any): boolean {
return Boolean(something);
}
toBoolean(1);
// 返回值為 true
型別斷言 vs 型別宣告
在這個例子中:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom = getCacheData('tom') as Cat;
tom.run();
我們使用 as Cat
將 any
型別斷言為了 Cat
型別。
但實際上還有其他方式可以解決這個問題:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom: Cat = getCacheData('tom');
tom.run();
上面的例子中,我們通過型別宣告的方式,將 tom
宣告為 Cat
,然後再將 any
型別的 getCacheData('tom')
賦值給 Cat
型別的 tom
。
這和型別斷言是非常相似的,而且產生的結果也幾乎是一樣的——tom
在接下來的程式碼中都變成了 Cat
型別。
它們的區別,可以通過這個例子來理解:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
const animal: Animal = {
name: 'tom'
};
let tom = animal as Cat;
在上面的例子中,由於 Animal
相容 Cat
,故可以將 animal
斷言為 Cat
賦值給 tom
。
但是若直接宣告 tom
為 Cat
型別:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
const animal: Animal = {
name: 'tom'
};
let tom: Cat = animal;
// index.ts:14:5 - error TS2741: Property 'run' is missing in type 'Animal' but required in type 'Cat'.
則會報錯,不允許將 animal
賦值為 Cat
型別的 tom
。
這很容易理解,Animal
可以看作是 Cat
的父類,當然不能將父類的例項賦值給型別為子類的變數。
深入的講,它們的核心區別就在於:
animal
斷言為Cat
,只需要滿足Animal
相容Cat
或Cat
相容Animal
即可animal
賦值給tom
,需要滿足Cat
相容Animal
才行
但是 Cat
並不相容 Animal
。
而在前一個例子中,由於 getCacheData('tom')
是 any
型別,any
相容 Cat
,Cat
也相容 any
,故
const tom = getCacheData('tom') as Cat;
等價於
const tom: Cat = getCacheData('tom');
知道了它們的核心區別,就知道了型別宣告是比型別斷言更加嚴格的。
所以為了增加程式碼的質量,我們最好優先使用型別宣告,這也比型別斷言的 as
語法更加優雅。
型別斷言 vs 泛型
本小節的前置知識點:泛型
還是這個例子:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom = getCacheData('tom') as Cat;
tom.run();
我們還有第三種方式可以解決這個問題,那就是泛型:
function getCacheData<T>(key: string): T {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom = getCacheData<Cat>('tom');
tom.run();
通過給 getCacheData
函式添加了一個泛型 <T>
,我們可以更加規範的實現對 getCacheData
返回值的約束,這也同時去除掉了程式碼中的 any
,是最優的一個解決方案。