1. 程式人生 > 其它 >TypeScript入門——型別斷言

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 是否是它的例項。

但是有的情況下 ApiErrorHttpError 不是一個真正的類,而只是一個 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 能夠被斷言為 BB 也能被斷言為 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 中的所有屬性,除此之外,它還有一個額外的方法 runTypeScript 並不關心 CatAnimal 之間定義時是什麼關係,而只會看它們最終的結構有什麼關係——所以它與 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 能夠被斷言為 BB 也能被斷言為 A

同理,若 B 相容 A,那麼 A 能夠被斷言為 BB 也能被斷言為 A

所以這也可以換一種說法:

要使得 A 能夠被斷言為 B,只需要 A 相容 BB 相容 A 即可,這也是為了在型別斷言時的安全考慮,畢竟毫無根據的斷言是非常危險的。

綜上所述:

  • 聯合型別可以被斷言為其中一個型別
  • 父類可以被斷言為子類
  • 任何型別都可以被斷言為 any
  • any 可以被斷言為任何型別
  • 要使得 A 能夠被斷言為 B,只需要 A 相容 BB 相容 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 肯定會報錯,因為 CatFish 互相都不相容。

但是若使用雙重斷言,則可以打破「要使得 A 能夠被斷言為 B,只需要 A 相容 BB 相容 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 Catany 型別斷言為了 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

但是若直接宣告 tomCat 型別:

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 相容 CatCat 相容 Animal 即可
  • animal 賦值給 tom,需要滿足 Cat 相容 Animal 才行

但是 Cat 並不相容 Animal

而在前一個例子中,由於 getCacheData('tom')any 型別,any 相容 CatCat 也相容 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,是最優的一個解決方案。