Typescript 進階與實踐(一)

Jason發表於2023-03-02

一、背景

在日常的開發工作中,我發現我們的前端工程都支援 TypeScript,而團隊內的同學在寫程式碼時還是以 JavaScript 為主,而其它的一些用到了 TypeScript 的程式碼,有很多都是在寫 “AnyScript”,用到的 TypeScript 的特性很少,也沒有把使用 TypeScript 所帶來的優點發揮出來,於是就有了這篇分享。

相對於只使用 JavaScript 來說,使用 TypeScript 的能帶來如下好處:

  1. 得益於 TypeScript 的靜態型別檢測,可以讓部分 JavaScript 錯誤在開發階段就被發現並解決;
  2. 使用 TypeScript 可以增加程式碼的可讀性和可維護性。在複雜的大型應用中,它能讓應用更易於維護、迭代,且穩定可靠,也會讓你更有安全感;
  3. TypeScript 的型別推斷與 IDE 結合帶來更好的程式碼智慧提示、重構,讓開發體驗和效率有了極大的提升;

這篇文章主要介紹 TypeScript 中的一些基礎特性、進階特性的使用,並結合個人的理解和實踐經驗,適用於對 TypeScript 已經有比較基礎的瞭解或者已經實際用過一段時間的前端開發者,希望能對大家有所幫助。

二、基礎

2.1 原始型別

這個比較基礎,參考 TypeScript: Documentation - built-in-types

2.2 內建全域性物件

JavaScript 中內建的裝箱型別NumberStringBoolean 等)以及其它內建物件(DateErrorArrayMapSetRegExpPromise 等)在 TypeScript 中都有其對應的同名型別。

宣告型別時需要注意這一點,在能使用 numberstringboolean 等原始型別來標註型別地方就不要使用 NumberStringBoolean 等包裝類的型別去標註型別,二者在 TypeScript 中不是完全等價的,如下面示例所示:

let primitiveNumber: number = 123;
let wrappedNumber: Number = 123;

wrappedNumber = primitiveNumber;
primitiveNumber = wrappedNumber; // @error: Type 'Number' is not assignable to type 'number'. (2322)

let primitiveString: string = 'hello';
let wrappedString: String = 'hello';

wrappedString = primitiveString;
primitiveString = wrappedString; // @error: Type 'String' is not assignable to type 'string'. (2322)

在實際開發場景中,我們幾乎使用不到 NumberStringBoolean 等型別,它們並沒有什麼特殊的用途。我們在寫 JavaScript 時,通常不會使用 NumberStringBoolean 等建構函式來 new 一個相應的例項。

2.3 字面量型別

除了原始型別 stringnumberboolean 之外,我們還可以將型別標註為特定的字串和數字、布林值。將變數型別標註為字面量型別後,它的值就需要與字面量型別對應的字面量值匹配,如下面示例所示:

const num1: 123 = 123;
const num2: 123 = 1234; // @error: Type '1234' is not assignable to type '123'.(2322)

const str1: 'Hello' = 'Hello';
const str2: 'Hello' = 'Hello world!'; // @error: Type '"Hello world!"' is not assignable to type '"Hello"'.(2322)

const bool1: true = true;
const bool2: true = false; // @error: Type 'false' is not assignable to type 'true'.(2322)

2.4 聯合型別 & 列舉

2.4.1 聯合型別

在宣告變數的型別時,我們一般不會將它限制為某一個字面量型別,一個只能有一個值的變數並沒有什麼用。所以我們一般會將字面量型別與聯合型別搭配使用。

聯合型別用來表示變數、引數的型別不是單一原子型別,而可能是多種不同的型別的組合,如下面示例所示:

let color: 'blue' | 'green' | 'red';

color = 'green';
color = 'blue';
color = 'red';
color = 'yellow'; // @error: Type '"yellow"' is not assignable to type '"blue" | "green" | "red"'.(2322)

function printText(s: string, alignment: 'left' | 'right' | 'center') {
  // ...
}

printText('Hello', 'left');
printText('Hello', 'center');
printText('Hello', 'top'); // @error: Argument of type '"top"' is not assignable to parameter of type '"left" | "right" | "center"'.

在一些場景中,TypeScript 針對聯合型別做了型別縮小最佳化,當聯合的成員同時存在子型別和父型別時,型別會只保留父型別,如下面示例所示:

/* 下面的型別會縮小到只保留父型別 */
type Str = 'string' | string; // 型別為 string
type Num = 2 | number; // 型別為 number
type Bool = true | boolean; // 型別為 boolean

這個最佳化削弱了 IDE 的自動提示能力。在 TypeScript 官方倉庫的 issue Literal String Union Autocomplete · Issue #29729 · microsoft/TypeScript 的討論中,TypeScript 官方提供了一個小技巧來使 IDE 的自動提示生效,如示例所示:

/* 在 IDE 中,給 color1 賦值時,不會獲得提示 */
type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string;
const color1: BorderColor = 'black';

/* 給父型別新增 “& {}” 後,就可以讓 IDE 的自動提示生效 */
type BGColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {};
const color2: BGColor = 'black';

2.4.2 列舉

列舉是 TypeScript 具有的少數幾個不是 JavaScript 型別級擴充套件的功能之一,其用法可參考TypeScript: Handbook - Enums。它與其它的型別有些不同,列舉兼具值和型別於一體。如示例所示:

 title=

在將 JavaScript 專案逐步升級到 TypeScript 時,專案中存在很多老的 JavaScript 程式碼,你可以將 TypeScript 中宣告的列舉匯入到 JavaScript 程式碼中使用。不過更推薦的做法是,使用諸如 airbnb/ts-migrate 之類的工具,快速將專案的程式碼都轉成 TypeScript,然後將型別檢查從寬鬆逐步過渡到嚴格。

常量列舉

透過新增 const 修飾符來定義常量列舉,常量列舉定義在編譯為 JavaScript 之後會被抹除,可以在一定程度上減少編譯後的 JavaScript 程式碼量,列舉成員的值會被直接內聯到使用了列舉成員的地方,使編譯後的產物結構更清晰,可讀性更高。如示例所示:

 title=

2.5 陣列 & 元組

2.5.1 陣列

在 TypeScript 中宣告陣列時,可以指定陣列元素的型別,如下面示例所示:

const numArr1: number[] = [1, 2, 3];
const strArr1: string[] = ['hello', 'world'];

const numArr2 = [1, 2, 3]; // 型別為 number[]
const strArr2 = ['hello', 'world']; // 型別為 string[]

你也可以用 Array<number> 之類的方式來宣告陣列,這個將在泛型部分講到。這兩種方式本質上並沒有區別,更推薦使用 number[] 的方式來宣告陣列,因為這種方式程式碼量更少。

2.5.2 元組

基本用法

元組型別與陣列型別有些相似,陣列和元組轉譯為 JavaScript 後都是陣列。它與陣列型別的區別在於它可以確切的宣告陣列中包含多少個元素以及各個元素的具體型別,如下面示例所示:

type StringNumberPair = [string, number];

const tuple1: StringNumberPair = ['age', 21];
const tuple2: StringNumberPair = ['age', 21, 22]; // @error: Type '[string, number, number]' is not assignable to type 'StringNumberPair'.(2322)

React 中的 useState hook 的返回值就是一個元組,它的型別定義類似於:

(state: State) => [State, SetState];

元組還經常用於宣告函式的引數的型別,如下面示例所示:

function add(...args: [number, number]) {
  const [x, y] = args;
  return x + y;
}
命名元組

上面示例中這種方式雖然可以宣告函式的引數型別,但是沒有包含函式的引數名資訊,如果引數數量比較多的話,這種宣告方式看起來就比較累了,於是官方在 TypeScript 4.0 中支援了給元組的成員命名,如下面示例所示:

type Tag = [name: string, value: string];

const tags: Tag[] = [
  ['成功', 'SUCEESS'],
  ['失敗', 'FAILURE'],
];

function add(a: number, b: number) {}

// 在 4.0 時, 這裡獲取到的引數型別為 [a: number, b: number]
type CenterMapParams = Parameters<typeof add>;

// 在 3.9 時, 型別看起來會是下面這樣
type OldCenterMapParams = [number, number];

2.6 函式

2.6.1 基本用法

函式是在 JavaScript 中傳遞資料的主要方式,在 TypeScript 中你可以為函式的引數和返回值指定型別,如下面示例所示:

// 宣告引數 a 和 b 的型別為 number
function add(a: number, b: number) {
  console.log(`${a} + ${b} = ${a + b}`);
}

// 宣告返回值的型別為 number
function getRandom(): number {
  return Math.random();
}

2.6.2 函式過載

JavaScript 是一門動態語言,針對同一個函式,它可以有多種不同型別的引數與返回值。而在 TypeScript 中,也可以相應地表達不同型別的引數和返回值的函式。

函式過載需要包含過載簽名和實現簽名,過載簽名的列表的各個成員必須是函式實現簽名的子集,如下面示例所示:

// 過載簽名
function len(s: string): number;
function len(arr: any[]): number;
// 實現簽名
function len(x: any) {
  return x.length;
}

函式實現時,TypeScript 不會限制函式實現中的返回值與過載簽名嚴格匹配,返回值型別只需要與實現簽名相容就行。如下面的示例所示:

function reflect(str: string): string;
function reflect(num: number): number;
function reflect(strOrNum: string | number): string | number {
  if (typeof strOrNum === 'string') {
    // 引數為 string 型別時,參考對應的過載簽名,返回值應該為 string 型別
    // 實際上你返回一個 number 也不會報錯,僅需要與實現簽名 string | number 相容
    return 123456;
  } else if (typeof strOrNum === 'number') {
    // 引數為 number 型別時,參考對應的過載簽名,返回值應該為 number 型別
    // 返回一個與實現簽名 string | number 不相容的型別時會報錯
    return false; // @error: Type 'boolean' is not assignable to type 'string | number'.(2322)
  } else {
    throw new Error('Invalid param strOrNum.');
  }
}

雖然 TypeScript 允許上面這種行為,但是實際開發場景中我們還是要避免這麼寫程式碼。

不要在更精確的過載簽名之前放置更寬泛的過載簽名,TypeScript 會從上到下查詢函式過載列表中與入參型別匹配的型別,並優先使用第一個匹配的過載定義。因此,放在前面函式過載簽名越精確越好,如下面反例所示:

// 下面這行新增了一個寬泛的過載簽名
function len(val: any): undefined;
// 過載簽名
function len(s: string): number;
function len(arr: any[]): number;
// 實現簽名
function len(x: any) {
  return x.length;
}

const r1 = len(''); // 匹配上第一個過載了,型別為 undefined

如果函式僅僅只有引數個數上的區別,那麼直接使用可選引數來宣告就行,沒有必要使用過載,如下面反例所示:

function diff(one: string): number; // 這行過載簽名寫或者不寫是沒有區別的
function diff(one: string, two?: string): number {
  return 0;
}

更多函式型別的用法參考 Documentation - More on Functions

2.7 物件

除了原始型別之外的其它所有型別都是物件型別。要定義物件型別,我們只需要列出它的屬性和型別。如下面示例所示:

// 一個接受物件型別引數的函式
function printCoord(pt: { x: number; y: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 3, y: 7 });

2.7.1 可選屬性

在屬性名後增加 ? 修飾即表示可選屬性,如下面示例所示:

function printCoord(pt: { x: number; y?: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 3 });
printCoord({ y: 3 }); // @error: Property 'x' is missing in type ……

2.7.2 只讀屬性

在屬性名前增加 readonly 即表示屬性為只讀,如下面示例所示:

function printCoord(pt: { readonly x: number; y?: number }) {
  pt.x = 4; // @error: Cannot assign to 'x' because it is a read-only property.(2540)
}

printCoord({ x: 3 });

這裡 readonly 只是在 TypeScript 靜態型別檢查層面上將屬性 x 的設定行為進行了攔截。在 JavaScript 執行時並不會產生影響。

2.7.3 object

object 型別表示任何非原始型別(stringnumberbigintbooleansymbolnullundefined)的型別。如下面示例所示:

const val1: object = 123456; // @error: (2322)
const val2: object = 'hello'; // @error: (2322)
const val3: object = undefined; // @error: (2322)
const val4: object = null; // @error: (2322)

// 下面這些不會報錯
const val5: object = { name: 'Jay' };
const val6: object = () => {};
const val7: object = [];

2.7.4 Object vs object vs {}

在進行物件型別標註時,我們可能會將物件字面量的 {} 、內建全域性物件 Objectobject 混淆,所以這裡再總結一下使用場景:

  1. 物件的裝箱型別為 Object,如章節 2.2 中提到的原因,不建議使用裝箱型別來標註型別;
  2. 當確定某個值是非原始值型別時,但又不知道它是什麼物件型別時,可以使用 object ,但是更推薦使用對映型別例如 Record<keyof any, unknown>   來表示;(keyof any 會根據 tsconfig.json 中的 keyofStringsOnly 配置來決定這裡的型別是 string 還是 string | number | symbol
  3. 型別 {} 可以表示任何非 null / undefined 的值,因此從型別安全的角度來說不推薦使用 {} 。當你想表示一個空物件時,可以使用 Record<keyof any, never> 代替;

2.8 型別別名 & 介面型別

2.8.1 型別別名

在之前的大部分示例程式碼中,我們將物件型別和聯合型別直接標註在變數或者屬性的型別上。直接標註雖然很方便,但如果要多次使用相同的型別來標註時,一處型別變動,就需要修改所有用了相同型別標註的地方,這樣會導致型別維護困難。因此,為了更好的複用型別, 我們可以為型別取一個名稱,然後在所用用到這個型別的地方標註這個型別別名,這樣就能解決這個問題了。如下面示例所示:

type User = {
  id: number;
  name: string;
  age: number;
};

function getCurrentUser(): User {
  return { id: 1, name: 'Jay', age: 21 };
}

function getUserList(): User[] {
  return [{ id: 1, name: 'Jay', age: 21 }];
}
交叉型別

當你想把多個物件型別合併到一起的時候,你可能會把多個物件的屬性重新宣告成一個物件,這樣還是可能會導致型別維護困難。而交叉型別就可以解決這個問題,交叉型別主要用於組合現有的物件型別,它的用法也很簡單,將 & 運算子放在兩個物件型別之間即可,如下面示例所示:

type Colorful = {
  color: string;
};

type Circle = {
  radius: number;
};

type ColorfulCircle = Colorful & Circle;

const obj1: ColorfulCircle = {
  // @error: Property 'radius' is missing in type……
  color: 'blue',
};

const obj2: ColorfulCircle = {
  color: 'blue',
  radius: 50,
};

2.8.2 介面型別

介面型別是 TypeScript 中宣告命名物件型別的另一種方式,如下面示例所示:

// 宣告瞭 Point 介面,要求必須要有 x 和 y 兩個屬性,且型別為 number
interface Point {
  x: number;
  y: number;
}

function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

// 傳入了符合 Point 介面描述的型別的物件
printCoord({ x: 100, y: 100 });

與物件型別一樣,介面型別可以在屬性前新增 readonly 將屬性變為只讀型別,也可以在屬性名後新增 ? 將屬性變為可選型別,如下面示例所示:

type User = {
  id: number;
  name: string;
  age: number;
};

function getCurrentUser(): User {
  return { id: 1, name: 'Jay', age: 21 };
}

function getUserList(): User[] {
  return [{ id: 1, name: 'Jay', age: 21 }];
}
宣告合併

介面具有宣告合併的特性,兩個同名的介面宣告會合併成一個介面宣告,如下面示例所示:

interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
}

let box: Box = { height: 5, width: 6, scale: 10 };

上面例子中的介面宣告等價於下面這段介面宣告:

interface Box {
  height: number;
  width: number;
  scale: number;
}
繼承

前面提到了型別別名可以透過交叉型別將多個物件型別進行組合,介面也可以使用交叉型別進行組合,如下面示例所示:

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

const obj1: ColorfulCircle = {
  // @error: Property 'radius' is missing in type……
  color: 'blue',
};

const obj2: ColorfulCircle = {
  color: 'blue',
  radius: 50,
};

除了使用交叉型別進行組合之外,還可以使用 extends 關鍵字進行組合,如下面示例所示:

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const obj1: ColorfulCircle = {
  // @error: Property 'radius' is missing in type……
  color: 'blue',
};

const obj2: ColorfulCircle = {
  color: 'blue',
  radius: 50,
};

2.8.3 型別別名 vs 介面型別

既然型別別名和介面型別都可以宣告命名物件型別,那它們之前有哪些區別呢?又有哪些適用場景呢?

區別
  1. 型別別名可以宣告原始型別、聯合型別、交叉型別、元組等型別,而介面不行;
  2. 型別別名不支援宣告合併,而介面支援;
  3. 型別別名主要使用交叉型別來組合物件,而介面主要使用 extends 關鍵字來組合物件;
  4. 在 TypeScript 4.2 版本之前,當型別檢查報錯時,使用介面型別在一些情況下可以獲得更具體的錯誤提示資訊,如下面示例所示;
/* 使用介面時,錯誤資訊中將會始終顯示介面名 */

interface Mammal {
  name: string;
}

function echoMammal(m: Mammal) {}

echoMammal({ name: 12343 }); // 滑鼠懸停提示錯誤與型別 Mammal 有關

/* 使用型別別名時,當型別未經過處理,可以正確顯示型別名稱 */

type Lizard = {
  name: string;
};

function echoLizard(l: Lizard) {}

echoLizard({ name: 12345 }); // 滑鼠懸停提示錯誤與型別 Lizard 有關

/* 使用型別別名時,當型別經過處理時,錯誤資訊就只會顯示轉換後的結果的型別,而不是型別名稱 */

type Arachnid = Omit<{ name: string; legs: 8 }, 'legs'>;

function echoSpider(l: Arachnid) {}

echoSpider({ name: 12345, legs: 8 }); // 滑鼠懸停提示錯誤與型別 Pick<{ name: string; legs: 8; }, "name"> 有關
使用場景

Interfaces create a single flat object type that detects property conflicts, which are usually important to resolve! Intersections on the other hand just recursively merge properties, and in some cases produce never. Interfaces also display consistently better, whereas type aliases to intersections can't be displayed in part of other intersections. Type relationships between interfaces are also cached, as opposed to intersection types as a whole. A final noteworthy difference is that when checking against a target intersection type, every constituent is checked before checking against the "effective"/"flattened" type.

—— Preferring Interfaces Over Intersections - Performance · microsoft/TypeScript Wiki

從上面這段引用我們可以得知:

  1. 介面會建立扁平的物件型別來檢測屬性是否衝突,解決這些衝突通常是很重要的。而交叉型別只是遞迴地合併屬性,在某些情況下將會產生 never 型別;在錯誤資訊中介面名會顯示的比較好,而交叉型別則不行。
  2. TypeScript 編譯器會快取介面間的型別關係,使用介面能獲得更好的效能,特別是在專案比較複雜時;

結合二者的區別以及效能差異,我們可以得出結論:介面型別更適合用來宣告物件型別,以及進行物件組合、繼承;型別別名更適合用於描述非結構化型別以及型別轉換等場景。

2.9 特殊型別

any

TypeScript 中有一個特殊型別 any ,它是官方提供的一個選擇性繞過靜態型別檢測的作弊方式。你可以在不希望特定值導致型別檢查錯誤時使用它。

當一個值是 any 型別時,你可以對它進行任何操作,例如:訪問它的任何屬性即使該屬性可能不存在、像函式一樣呼叫它,以及任何其它在語法上合法的東西,如示例所示:

let anything: any = {};

anything.doAnything(); // 不會提示錯誤
anything = 1; // 不會提示錯誤
anything = 'x'; // 不會提示錯誤

let num: number = anything; // 不會提示錯誤
let str: string = anything; // 不會提示錯誤

當我們將一個基於 JavaScript 的應用改造成 TypeScript 的過程中,我們可以藉助 any 來選擇性新增和忽略對某些 JavaScript 模組的靜態型別檢測,直至逐步替換掉所有的 JavaScript。或者已經引入了缺少型別註解的第三方元件庫時,就可以把這些值全部註解為 any 型別。

但是從長遠來看,使用 any 是一個壞習慣。如果一個 TypeScript 應用中充滿了 any,此時靜態型別檢測起不到作用,也就與直接使用 JavaScript 幾乎沒有區別了。因此,除非有充足的理由,否則應當儘量避免使用 any 。在專案中,我們可以在 tsconfig.json 中開啟 noImplicitAny   的配置項來限制 any 的使用。

unknown

unknownany 相似 ,它也可以表示任何值,但在型別上比 any 更安全。我們可以將任意型別的值賦值給 unknown,但 unknown 型別的值只能賦值給 unknownany,如下面示例所示:

let value: unknown;
let num: number = value; // @error: Type 'unknown' is not assignable to type 'number'. (2322)
let anything: any = value; // 不會提示錯誤

使用 unknown 後,TypeScript 會對它做型別檢測。如果不縮小型別,對 unknown 執行的任何操作都會出現錯誤。因此,對於未知型別的資料,使用 unknown 比使用 any 更好,如下面示例所示:

function fn1(value: any) {
  return value.toFixed(); // 不報錯
}

function fn2(value: unknown) {
  return value.toFixed(); // @error: 'value' is of type 'unknown'. (2571)
}

function fn3(value: unknown) {
  if (typeof value === 'number') {
    value.toFixed(); // 此處 hover 提示型別是 number,不會提示錯誤
  }
}

never

never 型別表示不攜帶任何型別。作為函式返回值時,意味著函式丟擲異常或程式終止執行,如下面示例所示:

// 函式因為永遠不會有返回值,所以它的返回值型別就是 never
function ThrowError(msg: string): never {
  throw Error(msg);
}

// 如果函式程式碼中是一個死迴圈,那麼這個函式的返回值型別也是 never
function InfiniteLoop(): never {
  while (true) {}
}

// 當聯合型別被縮小到什麼型別資訊都沒有時
function fn1(x: string | number) {
  if (typeof x === 'string') {
    // x 的型別在這個分支被縮小為 string
  } else if (typeof x === 'number') {
    // x 的型別在這個分支被縮小為 number
  } else {
    x; // 型別是 'never'!
  }
}

never 是所有型別的子型別,它可以賦值給所有型別,反過來,除了 never 自身以外的其它型別都不能賦值給 never 型別,如示例所示。

let unreachable: never = 1; // @error: (2322)

unreachable = 'string'; // @error: (2322)
unreachable = true; // @error: (2322)

let num: number = unreachable; // ok
let str: string = unreachable; // ok
let bool: boolean = unreachable; // ok

void

TypeScript 中 void 表示沒有返回值的函式。即如果函式沒有返回值,那它的型別就是 void,如示例所示:

// 滑鼠懸停在函式名上,顯示函式的返回值型別為 void
function noop() {
  return;
}

我們可以把 undefined 值或型別是 undefined 的變數賦值給 void 型別的變數,反過來,型別是 void 但值是 undefined 的變數不能賦值給 undefined 型別,如示例所示:

const userInfo: { id?: number } = {};

let undefinedType: undefined = undefined;
let voidType: void = undefined;

voidType = undefinedType; // ok
undefinedType = voidType; // @error: Type 'void' is not assignable to type 'undefined'. (2322)

function fn1(): void {
  return undefined;
}

function fn2(): undefined {
  const result: void = undefined;
  return result; // @error: Type 'void' is not assignable to type 'undefined'. (2322)
}

在給函式標註返回值型別時,返回值的型別應該僅為 void 或者為其它型別,不推薦將 void 與其它型別進行聯合,如示例所示:

// 什麼值都不會返回時使用 void
function fn1(): void {
  // ……
}

// 反例:函式某些情況下會有返回值,雖然型別檢查能透過,但不推薦這麼寫。
function fn3(val: unknown): number | string | void {
  if (typeof val === 'number' || typeof val === 'string') {
    return val;
  }
}

// 改進:將 void 替換成 undefined 。
function fn2(val: unknown): number | string | undefined {
  if (typeof val === 'number' || typeof val === 'string') {
    return val;
  }

  return;
}

2.10 型別斷言

2.10.1 基本用法

當你知道某個值的型別資訊,但是 TypeScript 不知道,就可以使用型別斷言。如下面示例所示:

const foo = {};
foo.bar = 123; // Error: 'bar' 屬性不存在於 ‘{}’
foo.bas = 'hello'; // Error: 'bas' 屬性不存在於 '{}'

這段程式碼發出了錯誤警告,因為 foo 的型別推斷為 {},即沒有屬性的物件。因此,你不能在它的屬性上新增 barbas,你可以透過型別斷言來避免此問題,如下面示例所示:

interface Foo {
  bar: number;
  bas: string;
}

const foo = {} as Foo; // 型別為 Foo

foo.bar = 123;
foo.bas = 'hello';

/**
 * 下面的這種方式與上面的沒有任何區別,但是由於尖括號格式會
 * 與 JSX 產生語法衝突,因此更推薦使用 as 語法。
 */
const foo1 = <Foo>{}; // 型別為 Foo

型別斷言僅允許將型別斷言為一個更具體的或者不太具體的型別,即僅在父子、子父型別之間可以使用型別斷言進行轉換。如下面示例所示;

/**
 * 斷言成更具體的型別
 */
function fn1(event: Event) {
  const mouseEvent = event as MouseEvent;
}

/**
 * 斷言成不那麼具體的型別
 */
function fn2(event: MouseEvent) {
  const mouseEvent = event as Event;
}

/**
 * 斷言成不可能的型別
 */
function fn3(event: Event) {
  const element = event as HTMLElement; // Error: 'Event' 和 'HTMLElement' 中的任何一個都不能賦值給另外一個
}

2.10.2 雙重斷言

使用雙重斷言可以將任何一個型別斷言為任何另一個型別,如下面示例所示,但是由於這種做法可能導致執行時錯誤,所以不推薦這麼做。

const num = 123 as any as string; // 型別為 string

const str = 'hello' as unknown as number; // 型別為 number

function fn3(event: Event) {
  const element = event as unknown as HTMLElement; // 型別為 HTMLElement
}

2.10.3 非空斷言

在值(變數、屬性)的後邊新增 ! 斷言運算子,它可以用來排除值為 nullundefined 的情況,如下面示例所示:

let mayNullOrUndefinedOrString: null | undefined | string;

mayNullOrUndefinedOrString!.toString(); // ok
mayNullOrUndefinedOrString.toString(); // @error: 'mayNullOrUndefinedOrString' is possibly 'null' or 'undefined'.(18049)

2.10.4 常量斷言

使用 字面量值 + as const 語法結構可以進行常量斷言,對資料進行常量斷言後,它的型別就變成了字面量型別且它的值不能再被修改,如下面示例所示:

let str = 'Hello world!' as const; // 型別為 "Hello world"
str = '123'; // Error: 2322

const readOnlyArr = [0, 1] as const; // 型別為 readonly [0, 1]
readOnlyArr[1] = 123; // Error: 2540

2.11 控制流分析

JavaScript 檔案中程式碼流動方式會影響整個程式的型別。讓我們來看一個例子:

const users = [{ name: 'Ahmed' }, { name: 'Gemma' }, { name: 'Jon' }]; // users 型別為 {name: string}[]
const jon = users.find(u => u.name === 'jon');

在這個例子中,find 可能會失敗,因為名字叫 “jon” 的使用者並不一定存在,因此變數 jon 的型別為 { name: string } | undefined 。而當你把滑鼠懸停在下面程式碼示例所示的三處 jon 上時,你將會看到型別如何根據 jon 所在的位置而變化:

if (jon) {
  // 型別為 { name: string } | undefined
  jon; // 型別為 { name: string }
} else {
  jon; // 型別為 undefined
}

像上面這種基於可達性的程式碼分析稱為控制流分析,TypeScript 在遇到型別保護和賦值時使用這種流分析來縮小型別。當分析變數時,控制流可以一次又一次地分離和重新合併,並且可以觀察到該變數在每個位置具有的不同型別。

另一個控制流分析的例子:

interface User {
  id: string;
  name: string;
  age: number;
}

type Action = { type: 'add'; user: User } | { type: 'delete'; id: string };

function addUser(user: User) {}
function deleteUser(id: string) {}

function reducer(action: Action) {
  switch (action.type) {
    case 'add':
      addUser(action.user); // action 是 "{ type: 'add', user: User }" 型別
      break;
    case 'delete':
      deleteUser(action.id); // action 是 "{ type: 'delete', id: string }" 型別
      break;
    default:
      throw new Error('Invalid action.');
  }
}

上面這段程式碼中,聯合型別 Action 中的物件成員都具有屬性 type,TypeScript 透過分析 switch 語句,就可以在對應的 case 分支中將型別縮小。

2.12 型別守衛

型別守衛是指透過程式碼來影響程式碼流分析。TypeScript 可以使用現有的 JavaScript 行為在執行時對值進行驗證以影響程式碼流。

JavaScript 中的一種常見模式是使用 typeofinstanceof 在執行時檢查表示式的型別。TypeScript 可以理解這些條件,並在 if 程式碼塊中使用時會相應地更改型別推斷,如下面示例所示:

let x: unknown;

// 使用 typeof 型別守衛
if (typeof x === 'string') {
  x.substring(1);
  x.subtr(2); // @error: Property 'subtr' does not exist on type 'string'. Did you mean 'substr'?(2551)
}

if (x instanceof Array) {
  x.split(''); // @error: Property 'split' does not exist on type 'any[]'.(2339)
  x.forEach(item => {
    console.log(item);
  });
}

除了 typeofinstanceof 之外,你還可以使用 in型別謂詞來做實現型別守衛,如下面示例所示:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

/* 使用 in 運算子縮小型別 */

function move1(animal: Fish | Bird) {
  if ('swim' in animal) {
    return animal.swim(); // 滑鼠懸停在 animal 上提示型別為 Fish
  }

  return animal.fly(); // 滑鼠懸停在 animal 上提示型別為 Bird
}

/* 使用型別謂詞實現型別守衛 */

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move2(animal: Fish | Bird) {
  if (isFish(animal)) {
    return animal.swim(); // 滑鼠懸停在 animal 上提示型別為 Fish
  }

  return animal.fly(); // 滑鼠懸停在 animal 上提示型別為 Bird
}

三、未完待續

由於時間和精力有限,第一部分內容就分享到這裡。後面還會給大家帶來 TypeScript 泛型、型別程式設計等內容,敬請期待。

相關文章