深入理解 TypeScript 高級用法

TypeScript 是一種類型安全的 JavaScript 超集,除了基本類型和對象類型之外,TypeScript 還提供了一些高級類型系統,使得我們可以更好地處理複雜的數據結構和業務邏輯。本文將深入探討 TypeScript 的高級類型系統,以更好地理解和使用這些高級類型,提高代碼的可讀性、可維護性和健壯性。

全文概覽:

  1. 字面量類型

  2. 聯合類型

  3. 交叉類型

  4. 索引類型

  5. 條件類型

  6. 類型推斷

  7. 類型保護

  8. 類型斷言

  9. 字面量類型


在 TypeScript 中,字面量不僅可以表示值,還可以表示類型,即字面量類型。TypeScript 支持以下字面量類型:

(1)字符串字面量類型

字符串字面量類型其實就是字符串常量,與字符串類型不同的是它是具體的值:

type Name = "TS";
const name1: Name = "test"; // ❌ 不能將類型“"test"”分配給類型“"TS"”。ts(2322)
const name2: Name = "TS";

實際上,定義單個字面量類型在實際應用中並沒有太大的用處。它的應用場景就是將多個字面量類型組合成一個聯合類型,用來描述擁有明確成員的實用的集合:

type Direction = "north" | "east" | "south" | "west";

function getDirectionFirstLetter(direction: Direction) {
  return direction.substr(0, 1);
}

getDirectionFirstLetter("test"); // ❌ 類型“"test"”的參數不能賦給類型“Direction”的參數。
getDirectionFirstLetter("east");

這個例子中使用四個字符串字面量類型組成了一個聯合類型。這樣在調用函數時,編譯器就會檢查傳入的參數是否是指定的字面量類型集合中的成員。通過這種方式,可以將函數的參數限定爲更具體的類型。這不僅提升了代碼的可讀性,還保證了函數的參數類型。

除此之外,使用字面量類型還可以爲我們提供智能的代碼提示:

(2)數字字面量類型

數字字面量類型就是指定類型爲具體的數值:

type Age = 18;

interface Info {
  name: string;
  age: Age;
}

const info: Info = {
  name: "TS",
  age: 28 // ❌ 不能將類型“28”分配給類型“18”
};

可以將數字字面量類型分配給一個數字,但反之是不行的:

let val1: 10|11|12|13|14|15 = 10;
let val2 = 10;

val2 = val1;
val1 = val2; // ❌ 不能將類型“number”分配給類型“10 | 11 | 12 | 13 | 14 | 15”。

(3)布爾字面量類型

布爾字面量類型就是指定類型爲具體的布爾值(truefalse):

let success: true;
let fail: false;
let value: true | false;

success = true;
success = false;  // ❌ 不能將類型“false”分配給類型“true”

由於布爾字面量類型只有truefalse兩種,所以下面 value 變量的類型是一樣的:

let value: true | false;
let value: boolean;

(4)模板字面量類型

在 TypeScript 4.1 版本中新增了模板字面量類型。什麼是模板字面量類型呢?它一字符串字面量類型爲基礎,可以通過聯合類型擴展成多個字符串。它與 JavaScript 的模板字符串語法相同,但是隻能用在類型定義中使用。

① 基本語法

當使用模板字面量類型時,它會替換模板中的變量,返回一個新的字符串字面量。

type attrs = "Phone" | "Name";
type target = `get${attrs}`;

// type target = "getPhone" | "getName";

可以看到,模板字面量類型的語法簡單,並且易讀且功能強大。

假如有一個 CSS 內邊距規則的類型,定義如下:

type CssPadding = 'padding-left' | 'padding-right' | 'padding-top' | 'padding-bottom';

上面的類型是沒有問題的,但是有點冗長。marginpadding 的規則相同,但是這樣寫我們無法重用任何內容,最終就會得到很多重複的代碼。

下面來使用模版字面量類型來解決上面的問題:

type Direction = 'left' | 'right' | 'top' | 'bottom';

type CssPadding = `padding-${Direction}`

// type CssPadding = 'padding-left' | 'padding-right' | 'padding-top' | 'padding-bottom'

這樣代碼就變得更加簡潔。如果想創建margin類型,就可以重用Direction類型:

type CssMargin = `margin-${Direction}`

如果在 JavaScript 中定義了變量,就可以使用 typeof 運算符來提取它:

const direction = 'left';

type CssPadding = `padding-${typeof direction}`;

// type CssPadding = "padding-left"

② 變量限制

模版字面量中的變量可以是任意的類型嗎?可以使用對象或自定義類型嗎?來看下面的例子:

type CustomObject = {
  foo: string
}

type target = `get${CustomObject}`
// ❌ 不能將類型“CustomObject”分配給類型“string | number | bigint | boolean | null | undefined”。

type complexUnion = string | number | bigint | boolean | null | undefined;

type target2 = `get${complexUnion}`  // ✅

可以看到,當在模板字面量類型中使用對象類型時,就報錯了,因爲編譯器不知道如何將它序列化爲字符串。實際上,模板字面量類型中的變量只允許是stringnumberbigintbooleannullundefined或這些類型的聯合類型。

③ 實用程序

Typescript 提供了一組實用程序來幫助處理字符串。它們不是模板字面量類型獨有的,但與它們結合使用時很方便。完整列表如下:

這些實用程序只接受一個字符串字面量類型作爲參數,否則就會在編譯時拋出錯誤:

type nameProperty = Uncapitalize<'Name'>;
// type nameProperty = 'name';

type upercaseDigit = Uppercase<10>;
// ❌ 類型“number”不滿足約束“string”。

type property = 'phone';
type UppercaseProperty = Uppercase<property>;
// type UppercaseProperty = 'Property';

下面來看一個更復雜的場景,將字符串字面量類型與這些實用程序結合使用。將兩種類型進行組合,並將第二種類型的首字母大小,這樣組合之後的類型符合駝峯命名法:

type actions = 'add' | 'remove';

type property = 'name' | 'phone';

type result = `${actions}${Capitalize<property>}`;
// type result = addName | addPhone | removeName | removePhone

④ 類型推斷

在上面的例子中,我們使用使用模版字面量類型將現有的類型組合成新類型。下面來看看如何使用模板字面量類型從組合的類型中提取類型。這裏就需要用到infer關鍵字,它允許我們從條件類型中的另一個類型推斷出一個類型。

下面來嘗試提取字符串字面量 marginRight 的根節點:

type Direction = 'left' | 'right' | 'top' | 'bottom';

type InferRoot<T> = T extends `${infer K}${Capitalize<Direction>}` ? K : T;

type Result1 = InferRoot<'marginRight'>;
// type Result1 = 'margin';

type Result2 = InferRoot<'paddingLeft'>;
// type Result2 = 'padding';

可以看到, 模版字面量還是很強大的,不僅可以創建類型,還可以解構它們。

⑤ 作爲判別式

在 TypeScript 4.5 版本中,支持了將**模板字面量串類型作爲判別式,**用於類型推導。來看下面的例子:

interface Message {
    type: string;
    url: string;
}

interface SuccessMessage extends Message {
    type: `${string}Success`;
    body: string;
}

interface ErrorMessage extends Message {
    type: `${string}Error`;
    message: string;
}

function handler(r: SuccessMessage | ErrorMessage) {
    if (r.type === "HttpSuccess") { 
        let token = r.body;
    }
}

在這個例子中,handler 函數中的 r 的類型就被推斷爲 SuccessMessage。因爲根據 SuccessMessageErrorMessage 類型中的 type 字段的模板字面量類型推斷出 HttpSucces 是根據SuccessMessage中的type創建的。

  1. 聯合類型

(1)基本使用

聯合類型是一種互斥的類型,該類型同時表示所有可能的類型。聯合類型可以理解爲多個類型的並集。 聯合類型用來表示變量、參數的類型不是某個單一的類型,而可能是多種不同的類型的組合,它通過 | 來分隔不同的類型:

type Union = "A" | "B" | "C";

在編寫一個函數時,該函數的期望參數是數字或者字符串,並根據傳遞的參數類型來執行不同的邏輯。這時就用到了聯合類型:

function direction(param: string | number) {
  if (typeof param === "string") {
    ...
  }
  if (typeof param === "number") {
    ...
  }
  ...
}

這樣在調用 direction 函數時,就可以傳入stringnumber類型的參數。當聯合類型比較長或者想要複用這個聯合類型的時候,就可以使用類型別名來定義聯合類型:

type Params = string | number | boolean;

再來看一個字符串字面量聯合類型的例子,setStatus 函數只能接受某些特定的字符串值,就可以將這些字符串字面量組合成一個聯合類型:

type Status = 'not_started' | 'progress' | 'completed' | 'failed';

const setStatus = (status: Status) ={
  db.object.setStatus(status);
};

setStatus('progress');
setStatus('offline'); // ❌ 類型“"offline"”的參數不能賦給類型“Status”的參數。

在調用函數時,如果傳入的參數不是聯合類型中的值,就會報錯。

(2)限制

聯合類型僅在編譯時是可用的,這意味着我們不能遍歷這些值。進行如下嘗試:

type Status = 'not_started' | 'progress' | 'completed' | 'failed';

console.log(Object.values(Status)); // ❌ “Status”僅表示類型,但在此處卻作爲值使用。

這時就會拋出一個錯誤,告訴我們不能將 Status 類型當做值來使用。

如果想要遍歷這些值,可以使用枚舉來實現:

enum Status {
  'not_started',
  'progress',
  'completed',
  'failed'
}

console.log(Object.values(Status));

(3)可辨識聯合類型

在使用聯合類型時,如何來區分聯合類型中的類型呢?類型保護是一種條件檢查,可以幫助我們區分類型。在這種情況下,類型保護可以讓我們準確地確定聯合中的類型(下文會詳細介紹類型保護)。

有很多方式可以做到這一點,這很大程度上取決於聯合類型中包含哪些類型。有一條捷徑可以使聯合類型中的類型之間的區分變得容易,那就是可辨識聯合類型。可辨識聯合類型是聯合類型的一種特殊情況,它允許我們輕鬆的區分其中的類型。

這是通過向具有唯一值的每個類型中添加一個字段來實現的,該字段用於使用相等類型保護來區分類型。例如,有一個表示所有可能的形狀的聯合類型,根據傳入的不同類型的形狀來計算該形狀的面積,代碼如下:

type Square = {
  kind: "square";
  size: number;
}

type Rectangle = {
  kind: "rectangle";
  height: number;
  width: number;
}

type Circle = {
  kind: "circle";
  radius: number;
}

type Shape = Square | Rectangle | Circle; 

function getArea(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
    case "rectangle":
      return s.height * s.width;
    case "circle":
      return Math.PI * s.radius ** 2;
  }
}

在這個例子中,Shape 就是一個可辨識聯合類型,它是三個類型的聯合,而這三個類型都有一個 kind 屬性,且每個類型的 kind 屬性值都不相同,能夠起到標識作用。函數內應該包含聯合類型中每一個接口的 case以保證每個**case**都能被處理。

如果函數內沒有包含聯合類型中每一個類型的 case,在編寫代碼時希望編譯器應該給出代碼提示,可以使用以下兩種完整性檢查的方法。

① strictNullChecks

對於上面的例子,先來新增一個類型,整體代碼如下:

type Square = {
  kind: "square";
  size: number;
}

type Rectangle = {
  kind: "rectangle";
  height: number;
  width: number;
}

type Circle = {
  kind: "circle";
  radius: number;
}

type Triangle = {
  kind: "triangle";
  bottom: number;
  height: number;
}

type Shape = Square | Rectangle | Circle | Triangle; 

function getArea(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
    case "rectangle":
      return s.height * s.width;
    case "circle":
      return Math.PI * s.radius ** 2;
  }
}

這時,Shape 聯合類型中有四種類型,但函數的 switch 裏只包含三個 case,這個時候編譯器並沒有提示任何錯誤,因爲當傳入函數的是類型是 Triangle 時,沒有任何一個 case 符合,則不會執行任何 return 語句,那麼函數是默認返回 undefined。所以可以利用這個特點,結合 strictNullChecks 編譯選項,可以在tsconfig.json配置文件中開啓 strictNullChecks

{
  "compilerOptions"{
    "strictNullChecks": true,
  }
}

讓函數的返回值類型爲 number,那麼當返回 undefined 時就會報錯:

function getArea(s: Shape): number {
    case "square":
      return s.size * s.size;
    case "rectangle":
      return s.height * s.width;
    case "circle":
      return Math.PI * s.radius ** 2;
  }
}

上面的number處就會報錯:

② never

當函數返回一個錯誤或者不可能有返回值的時候,返回值類型爲 never。所以可以給 switch 添加一個 default 流程,當前面的 case 都不符合的時候,會執行 default 中的邏輯:

function assertNever(value: never): never {
  throw new Error("Unexpected object: " + value);
}

function getArea(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
    case "rectangle":
      return s.height * s.width;
    case "circle":
      return Math.PI * s.radius ** 2;
    default:
      return assertNever(s); // error 類型“Triangle”的參數不能賦給類型“never”的參數
  }
}

採用這種方式,需要定義一個額外的 asserNever 函數,但是這種方式不僅能夠在編譯階段提示遺漏了判斷條件,而且在運行時也會報錯。

  1. 交叉類型

(1)基本實用

交叉類型是將多個類型合併爲一個類型。這讓我們可以把現有的多種類型疊加到成爲一種類型,合併後的類型將擁有所有成員類型的特性。交叉類型可以理解爲多個類型的交集。 可以使用以下語法來創建交叉類型,每種類型之間使用 & 來分隔:

type Types = type1 & type2 & .. & .. & typeN;

如果我們僅僅把原始類型、字面量類型、函數類型等原子類型合併成交叉類型,是沒有任何意義的,因爲不會有變量同時滿足這些類型,那這個類型實際上就等於never類型。

(2)使用場景

上面說了,一般情況下使用交叉類型是沒有意義的,那什麼時候該使用交叉類型呢?下面就來看看交叉類型的使用場景。

① 合併接口類型

交叉類型的一個常見的使用場景就是將多個接口合併成爲一個:

type Person = {
 name: string;
  age: number;
} & {
 height: number;
  weight: number;
} & {
 id: number;
}

const person: Person = {
 name: "zhangsan",
  age: 18,
  height: 180,
  weight: 60,
  id: 123456
}

這裏就通過交叉類型使 Person 同時擁有了三個接口中的五個屬性。那如果兩個接口中的同一個屬性定義了不同的類型會發生了什麼情況呢?

type Person = {
 name: string;
  age: number;
} & {
  age: string;
 height: number;
  weight: number;
}

兩個接口中都擁有age屬性,並且類型分別是numberstring,那麼在合併後,age的類型就是string & number,也就是 never 類型:

const person: Person = {
 name: "zhangsan",
  age: 18,   // ❌ 不能將類型“number”分配給類型“never”。
  height: 180,
  weight: 60,
}

如果同名屬性的類型兼容,比如一個是 number,另一個是 number 的子類型——數字字面量類型,合併後 age 屬性的類型就是兩者中的子類型:

type Person = {
 name: string;
  age: number;
} & {
  age: 18;
 height: number;
  weight: number;
}

const person: Person = {
 name: "zhangsan",
  age: 20,  // ❌ 不能將類型“20”分配給類型“18”。
  height: 180,
  weight: 60,
}

第二個接口中的age是一個數字字面量類型,它是number類型的子類型,所以合併之後的類型爲字面量類型18

② 合併聯合類型

交叉類型另外一個常見的使用場景就是合併聯合類型。可以將多個聯合類型合併爲一個交叉類型,這個交叉類型需要同時滿足不同的聯合類型限制,也就是提取了所有聯合類型的相同類型成員:

type A = "blue" | "red" | 999;
type B = 999 | 666;
type C = A & B; // type C = 999;

const c: C = 999;

如果多個聯合類型中沒有相同的類型成員,那麼交叉出來的類型就是never類型:

type A = "blue" | "red";
type B = 999 | 666;
type C = A & B;

const c: C = 999; // ❌ 不能將類型“number”分配給類型“never”。
  1. 索引類型

在介紹索引類型之前,先來了解兩個類型操作符:索引類型查詢操作符索引訪問操作符

(1)索引類型查詢操作符

使用 keyof 操作符可以返回一個由這個類型的所有屬性名組成的聯合類型:

type UserRole = 'admin' | 'moderator' | 'author';

type User = {
  id: number;
  name: string;
  email: string;
  role: UserRole;
}

type UserKeysType = keyof User; // 'id' | 'name' | 'email' | 'role';

(2)索引訪問操作符

索引訪問操作符就是[],其實和訪問對象的某個屬性值是一樣的語法,但是在 TS 中它可以用來訪問某個屬性的類型:

type User = {
  id: number;
  name: string;
  address: {
    street: string;
    city: string;
    country: string;
  };
}

type Params = {
  id: User['id'],
  address: User['address']
}

這裏我們沒有使用number來描述id屬性,而是使用 User['id'] 引用User中的id屬性的類型,這種類型成爲索引類型,它們看起來與訪問對象的屬性相同,但訪問的是類型。

當然,也可以訪問嵌套屬性的類型:

type City = User['address']['city']; // string

可以通過聯合類型來一次獲取多個屬性的類型:

type IdOrName = User['id' | 'name']; // string | number

(3)應用

我們可以使用以下方式來獲取給定對象中的任何屬性:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

TypeScript 會推斷此函數的返回類型爲 T[K],當調用  getProperty 函數時,TypeScript 將推斷我們將要讀取的屬性的實際類型:

const user: User = {
  id: 15,
  name: 'John',
  email: 'john@smith.com',
  role: 'admin'
};

getProperty(user, 'name'); // string
getProperty(user, 'id');   // number

name屬性被推斷爲string類型,age屬性被推斷爲number類型。當訪問 User 中不存在的屬性時,就會報錯:

getProperty(user, 'property'); // ❌ 類型“"property"”的參數不能賦給類型“keyof User”的參數。
  1. 條件類型

(1)基本概念

條件類型根據條件來選擇兩種可能的類型之一,就像 JavaScript 中的三元運算符一樣。其語法如下所示:

T extends U ? X : Y

上述類型就意味着當 T 可分配給(或繼承自)U 時,類型爲 X,否則類型爲 Y

看一個簡單的例子,一個值可以是用戶的出生日期或年齡。如果是出生日期,那麼這個值的類型就是 number;如果是年齡,那這個值的類型就是 string。下面定義了三個類型:

type Dob = string;
type Age = number;
type UserAgeInformation<T> = T extends number ? number : string;

其中 TUserAgeInformation 的泛型參數,可以在這裏傳遞任何類型。如果 T 擴展了 number,那麼類型就是 number,否則就是 string。如果希望 UserAgeInformationnumber,就可以將 Age 傳遞給 T,如果希望是一個 string,就可以將 Dob 傳遞給 T

type Dob = string;
type Age = number;
type UserAgeInformation<T> = T extends number ? number : string;

let userAge:UserAgeInformation<Age> = 100;
let userDob:UserAgeInformation<Dob> = '25/04/1998';

(2)創建自定義條件類型

單獨使用條件類型可能用處不是很大,但是結合泛型使用時就非常有用。一個常見的用例就是使用帶有 never 類型的條件類型來修剪類型中的值。

type NullableString = string | null;

let itemName: NullableString;
itemName = null;
itemName = "Milk";

console.log(itemName);

其中 NullableString 可以是 stringnull 類型,它用於 itemName 變量。定義一個名爲 NoNull 的類型別名:

type NoNull<T>

我們想從類型中剔除 null,需要通過條件來檢查類型是否包含 null

type NoNull<T> = T extends null;

當這個條件爲 true 時,不想使用該類型,返回 never 類型:

type NoNull<T> = T extends null ? never

當這個條件爲 false 時,說明類型中不包含 null,可以直接返回 T

type NoNull<T> = T extends null ? never : T;

itemName 變量的類型更改爲 NoNull

let itemName: NoNull<NullableString>;

TypeScript 有一個類似的實用程序類型,稱爲 NonNullable,其實現如下:

type NonNullable<T> = T extends null | undefined ? never : T;

NonNullableNoNull 之間的區別在於 NonNullable 將從類型中刪除 undefined 以及 null

(3)條件類型的類型推斷

條件類型提供了一個infer關鍵字用來推斷類型。下面來定義一個條件類型,如果傳入的類型是一個數組,則返回數組元素的類型;如果是一個普通類型,則直接返回這個類型。如果不使用  infer 可以這樣寫:

type Type<T> = T extends any[] ? T[number] : T;

type test = Type<string[]>; // string
type test2 = Type<string>;  // string

如果傳入 Type 的是一個數組類型,那麼返回的類型爲T[number],即該數組的元素類型,如果不是數組,則直接返回這個類型。這裏通過索引訪問類型T[number]來獲取類型,如果使用 infer 關鍵字則無需手動獲取:

type Type<T> = T extends Array<infer U> ? U : T;

type test = Type<string[]>; // string
type test2 = Type<string>;  // string

這裏 infer 能夠推斷出 U 的類型,並且供後面使用,可以理解爲這裏定義了一個變量 U 來接收數組元素的類型。

  1. 類型推斷

(1)基礎類型

在變量的定義中如果沒有明確指定類型,編譯器會自動推斷出其類型:

let name = "zhangsan";
name = 123; // error 不能將類型“123”分配給類型“string”

在定義變量 name 時沒有指定其類型,而是直接給它賦一個字符串。當再次給 name 賦一個數值時,就會報錯。這裏,TypeScript 根據賦給 name 的值的類型,推斷出 name 是 string 類型,當給 string 類型的 name 變量賦其他類型值的時候就會報錯。這是最基本的類型推論,根據右側的值推斷左側變量的類型。

(2)多類型聯合

當定義一個數組或元組這種包含多個元素的值時,多個元素可以有不同的類型,這時 TypeScript 會將多個類型合併起來,組成一個聯合類型:

let arr = [1, "a"];
arr = ["b", 2, false]; // error 不能將類型“false”分配給類型“string | number”

可以看到,此時的 arr 中的元素被推斷爲string | number,也就是元素可以是 string 類型也可以是 number 類型,除此之外的類型是不可以的。

再來看一個例子:

let value = Math.random() * 10 > 5 ? 'abc' : 123
value = false // error 不能將類型“false”分配給類型“string | number”

這裏給value賦值爲一個三元表達式的結果,Math.random() * 10的值爲 0-10 的隨機數。如果這個隨機值大於 5,則賦給 value的值爲字符串abc,否則爲數值123。所以最後編譯器推斷出的類型爲聯合類型string | number,當給它再賦值false時就會報錯。

(3)上下文類型

上面的例子都是根據=右側值的類型,推斷左側值的類型。而上下文類型則相反,它是根據左側的類型推斷右側的類型:

window.onmousedown = function(mouseEvent) {
  console.log(mouseEvent.a); // error 類型“MouseEvent”上不存在屬性“a”
};

可以看到,表達式左側是 window.onmousedown(鼠標按下時觸發),因此 TypeScript 會推斷賦值表達式右側函數的參數是事件對象,因爲左側是 mousedown 事件,所以 TypeScript 推斷 mouseEvent 的類型是 MouseEvent。在回調函數中使用 mouseEvent 時,可以訪問鼠標事件對象的所有屬性和方法,當訪問不存在屬性時,就會報錯。

  1. 類型保護

類型保護實際上是一種錯誤提示機制,類型保護是可執行運行時檢查的一種表達式,用於確保該類型在一定的範圍內。類型保護的主要思想是嘗試檢測屬性、方法或原型,以確定如何處理值。

(1)instanceof 類型保護

instanceof是一個內置的類型保護,可用於檢查一個值是否是給定構造函數或類的實例。通過這種類型保護,可以測試一個對象或值是否是從一個類派生的,這對於確定實例的類型很有用。

instanceof 類型保護的基本語法如下:

objectVariable instanceof ClassName ;

來看一個例子:

class CreateByClass1 {
  public age = 18;
  constructor() {}
}

class CreateByClass2 {
  public name = "TypeScript";
  constructor() {}
}

function getRandomItem() {
  return Math.random() < 0.5 
    ? new CreateByClass1() 
    : new CreateByClass2(); // 如果隨機數小於0.5就返回CreateByClass1的實例,否則返回CreateByClass2的實例
}

const item = getRandomItem();

// 判斷item是否是CreateByClass1的實例
if (item instanceof CreateByClass1) { 
  console.log(item.age);
} else {
  console.log(item.name);
}

這裏 if 的判斷邏輯中使用 instanceof 操作符判斷 item 。如果是 CreateByClass1 創建的,那它就有 age 屬性;如果不是,那它就有 name 屬性。

(2)typeof 類型保護

typeof 類型保護用於確定變量的類型,它只能識別以下類型:

對於這個列表之外的任何內容,typeof 類型保護只會返回 objecttypeof 類型保護可以寫成以下兩種方式:

typeof v !== "typename"

typeof v === "typename"

typename 只能是numberstringbooleansymbol四種類型,在 TS 中,只會把這四種類型的 typeof 比較識別爲類型保護。

在下面的例子中,StudentId 函數有一個 string | number 聯合類型的參數 x。如果變量 x 是字符串,則會打印 Student;如果是數字,則會打印 Idtypeof 類型保護可以從 x 中提取類型:

function StudentId(x: string | number) {
    if (typeof x == 'string') {
        console.log('Student');
    }
    if (typeof x === 'number') {
        console.log('Id');
    }
}

StudentId(`446`); // Student
StudentId(446);   // Id

(3)in 類型保護

in 類型保護可以檢查對象是否具有特定屬性。它通常返回一個布爾值,指示該屬性是否存在於對象中。

in 類型保護的基本語法如下:

propertyName in objectName

來看一個例子:

interface Person {
  firstName: string;
  surname: string;
}

interface Organisation {
  name: string;
}

type Contact = Person | Organisation;

function sayHello(contact: Contact) {
  if ("firstName" in contact) {
    console.log(contact.firstName);
  }
}

in 類型保護檢查參數 contact 對象中是否存在 firstName屬性。如果存在,就進入if 判斷,打印contact.firstName的值。

(4)自定義類型保護

來看一個例子:

const valueList = [123, "abc"];

const getRandomValue = () ={
  const number = Math.random() * 10; // 這裏取一個[0, 10)範圍內的隨機值
  if (number < 5) {
    return valueList[0]; // 如果隨機數小於5則返回valueList裏的第一個值,也就是123
  }else {
    return valueList[1]; // 否則返回"abc"
  }
};

const item = getRandomValue();

if (item.length) {
  console.log(item.length); // error 類型“number”上不存在屬性“length”
} else {
  console.log(item.toFixed()); // error 類型“string”上不存在屬性“toFixed”
}

這裏,getRandomValue 函數返回的元素是不固定的,有時返回 number 類型,有時返回 string 類型。使用這個函數生成一個值 item,然後通過是否有 length 屬性來判斷是 string 類型,如果沒有 length 屬性則爲 number 類型。在 JavaScript 中,這段邏輯是沒問題的。但是在 TypeScript 中,因爲 TS 在編譯階段是無法識別 item 的類型的,所以當在 if 判斷邏輯中訪問 itemlength 屬性時就會報錯,因爲如果 itemnumber 類型的話是沒有 length 屬性的。

這個問題可以通過類型斷言來解決,修改判斷邏輯即可:

if ((<string>item).length) {
  console.log((<string>item).length);
} else {
  console.log((<number>item).toFixed());
}

這裏通過使用類型斷言告訴 TS 編譯器,if 中的 itemstring 類型,而 else 中的是 number 類型。這樣做雖然可以,但是需要在使用 item 的地方都使用類型斷言來說明,顯然有些繁瑣。

可以使用自定義類型保護來解決上述問題:

const valueList = [123, "abc"];

const getRandomValue = () ={
  const number = Math.random() * 10; // 這裏取一個[0, 10)範圍內的隨機值
  if (number < 5) return valueList[0]; // 如果隨機數小於5則返回valueList裏的第一個值,也就是123
  else return valueList[1]; // 否則返回"abc"
};

function isString(value: number | string): value is string {
  const number = Math.random() * 10
  return number < 5;
}

const item = getRandomValue();

if (isString(item)) {
  console.log(item.length); // 此時item是string類型
} else {
  console.log(item.toFixed()); // 此時item是number類型
}

首先定義一個函數,函數的參數 value 就是要判斷的值。這裏 value 的類型可以爲 numberstring,函數的返回值類型是一個結構爲 value is type 的類型謂語,value 的命名無所謂,但是謂語中的 value 名必須和參數名一致。而函數里的邏輯則用來返回一個布爾值,如果返回爲 true,則表示傳入的值類型爲is後面的 type

使用類型保護後,if 的判斷邏輯和代碼塊都無需再對類型做指定工作,不僅如此,既然 itemstring類型,則 else 的邏輯中,item 一定是聯合類型中的另外一個,也就是 number 類型。

  1. 類型斷言

(1)基本使用

TypeScrip 的類型系統很強大,但有時它是不如我們更瞭解一個值的類型。這時,我們更希望 TypeScript 不要進行類型檢查,而是讓我們自己來判斷,這時就用到了類型斷言。

使用類型斷言可以手動指定一個值的類型。類型斷言像是一種類型轉換,它把某個值強行指定爲特定類型,下面來看一個例子:

const getLength = target ={
  if (target.length) {
    return target.length;
  } else {
    return target.toString().length;
  }
};

這個函數接收一個參數,並返回它的長度。這裏傳入的參數可以是字符串、數組或是數值等類型的值,如果有 length 屬性,說明參數是數組或字符串類型,如果是數值類型是沒有 length 屬性的,所以需要把數值類型轉爲字符串然後再獲取 length 值。現在限定傳入的值只能是字符串或數值類型的值:

const getLength = (target: string | number)number ={
  if (target.length) { // error 類型"string | number"上不存在屬性"length"
    return target.length; // error  類型"number"上不存在屬性"length"
  } else {
    return target.toString().length;
  }
};

當 TypeScript 不確定一個聯合類型的變量到底是哪個類型時,就只能訪問此聯合類型的所有類型裏共有的屬性或方法,所以現在加了對參數target和返回值的類型定義之後就會報錯。

這時就可以使用類型斷言,將target的類型斷言成string類型。它有兩種寫法:<type>valuevalue as type

// 這種形式是沒有任何問題的,建議使用這種形式
const getStrLength = (target: string | number)number ={
  if ((target as string).length) {      
    return (target as string).length; 
  } else {
    return target.toString().length;
  }
};

// 這種形式在JSX代碼中不可以使用,而且也是TSLint不建議的寫法
const getStrLength = (target: string | number)number ={
  if ((<string>target).length) {      
    return (<string>target).length; 
  } else {
    return target.toString().length;
  }
};

類型斷言不是類型轉換,斷言成一個聯合類型中不存在的類型是不允許的。

注意: 不要濫用類型斷言,在萬不得已的情況下使用要謹慎,因爲強制把某類型斷言會造成 TypeScript 喪失代碼提示的能力。

(2)雙重斷言

雖然類型斷言是強制性的,但並不是萬能的,在一些情況下會失效:

interface Person {
 name: string;
 age: number;
}
const person = 'ts' as Person; // Error

這時就會報錯,很顯然不能把 string 強制斷言爲一個接口 Person ,但是並非沒有辦法,此時可以使用雙重斷言:

interface Person {
 name: string;
 age: number;
}
const person = 'ts' as any as Person;

先把類型斷言爲 any ,再接着斷言爲想斷言的類型就能實現雙重斷言,當然上面的例子肯定說不通的,雙重斷言我們也更不建議濫用,但是在一些少見的場景下也有用武之地。

(3)顯式賦值斷言

先來看兩個關於nullundefined的知識點。

① 嚴格模式下 null 和 undefined 賦值給其它類型值

當在 tsconfig.json 中將 strictNullChecks 設爲 true 後,就不能再將 undefinednull 賦值給除它們自身和void 之外的任意類型值了,但有時確實需要給一個其它類型的值設置初始值爲空,然後再進行賦值,這時可以自己使用聯合類型來實現 nullundefined 賦值給其它類型:

let str = "ts";
str = null; // error 不能將類型“null”分配給類型“string”
let strNull: string | null = "ts"; // 這裏你可以簡單理解爲,string | null即表示既可以是string類型也可以是null類型
strNull = null; // right
strNull = undefined; // error 不能將類型“undefined”分配給類型“string | null”

注意,TS 會將 undefinednull 區別對待,這和 JavaScript 的本意也是一致的,所以在 TS 中,string|undefinedstring|nullstring|undefined|null是三種不同的類型。

② 可選參數和可選屬性

如果開啓了 strictNullChecks,可選參數會被自動加上 |undefined

const sum = (x: number, y?: number) ={
  return x + (|| 0);
};
sum(1, 2); // 3
sum(1); // 1
sum(1, undefined); // 1
sum(1, null); // error Argument of type 'null' is not assignable to parameter of type 'number | undefined'

根據錯誤信息看出,這裏的參數 y 作爲可選參數,它的類型就不僅是 number 類型了,它可以是 undefined,所以它的類型是聯合類型 number | undefined

TypeScript 對可選屬性和對可選參數的處理一樣,可選屬性的類型也會被自動加上 |undefined

interface PositionInterface {
  x: number;
  b?: number;
}
const position: PositionInterface = {
  x: 12
};
position.b = "abc"; // error
position.b = undefined; // right
position.b = null; // error

看完這兩個知識點,再來看看顯式賦值斷言。當開啓 strictNullChecks 時,有些情況下編譯器是無法在聲明一些變量前知道一個值是否是 null 的,所以需要使用類型斷言手動指明該值不爲 null。下面來看一個編譯器無法推斷出一個值是否是null的例子:

function getSplicedStr(num: number | null): string {
  function getRes(prefix: string) { // 這裏在函數getSplicedStr裏定義一個函數getRes,我們最後調用getSplicedStr返回的值實際是getRes運行後的返回值
    return prefix + num.toFixed().toString(); // 這裏使用參數num,num的類型爲number或null,在運行前編譯器是無法知道在運行時num參數的實際類型的,所以這裏會報錯,因爲num參數可能爲null
  }
  num = num || 0.1; // 這裏進行了賦值,如果num爲null則會將0.1賦給num,所以實際調用getRes的時候,getRes裏的num拿到的始終不爲null
  return getRes("lison");
}

因爲有嵌套函數,而編譯器無法去除嵌套函數的 null(除非是立即調用的函數表達式),所以需要使用顯式賦值斷言,寫法就是在不爲 null 的值後面加個!。上面的例子可以這樣改:

function getSplicedStr(num: number | null): string {
  function getLength(prefix: string) {
    return prefix + num!.toFixed().toString();
  }
  num = num || 0.1;
  return getLength("lison");
}

這樣編譯器就知道 num 不爲 null,即便 getSplicedStr 函數在調用的時候傳進來的參數是 null,在 getLength 函數中的 num 也不會是 null

(4)const 斷言

const 斷言是 TypeScript 3.4 中引入的一個實用功能。在 TypeScript 中使用 as const 時,可以將對象的屬性或數組的元素設置爲只讀,向語言表明表達式中的類型不會被擴大(例如從 42 到 number)。

function sum(a: number, b: number) {
  return a + b;
}

// 相當於 const arr: readonly [3, 4]
const arr = [3, 4] as const;

console.log(sum(...arr)); // 7

這裏創建了一個 sum 函數,它以 2 個數字作爲參數並返回其總和。const 斷言使我們能夠告訴 TypeScript 數組的類型不會被擴展,例如從 [3, 4]number[]。通過 as const,使得數組成爲只讀元組,因此其內容是無法更改的,可以在調用 sum 函數時安全地使用這兩個數字。

如果試圖改變數組的內容,會得到一個錯誤:

function sum(a: number, b: number) {
  return a + b;
}

// 相當於 const arr: readonly [3, 4]
const arr = [3, 4] as const;

// 類型“readonly [3, 4]”上不存在屬性“push”。
arr.push(5);

因爲使用了 const 斷言,因此數組現在是一個只讀元組,其內容無法更改,並且嘗試這樣做會在開發過程中導致錯誤。

如果嘗試在不使用 const 斷言的情況下調用 sum 函數,就會得到一個錯誤:

function sum(a: number, b: number) {
  return a + b;
}

// 相當於 const arr: readonly [3, 4]
const arr = [3, 4];

// 擴張參數必須具有元組類型或傳遞給 rest 參數。
console.log(sum(...arr)); // 👉️ 7

TypeScript 警告我們,沒有辦法知道 arr 變量的內容在其聲明和調用 sum() 函數之間沒有變化。

如果不喜歡使用 TypeScript 中的枚舉,也可以使用 const 斷言作爲枚舉的替代品:

// 相當於 const Pages: {readonly home: '/'; readonly about: '/about'...}
export const Pages = {
  home: '/',
  about: '/about',
  contacts: '/contacts',
} as const;

如果嘗試更改對象的任何屬性或添加新屬性,就會收到錯誤消息:

// 相當於 const Pages: {readonly home: '/'; readonly about: '/about'...}
export const Pages = {
  home: '/',
  about: '/about',
  contacts: '/contacts',
} as const;

// 無法分配到 "about" ,因爲它是隻讀屬性。
Pages.about = 'hello';

// 類型“{ readonly home: "/"; readonly about: "/about"; readonly contacts: "/contacts"; }”上不存在屬性“test”。
Pages.test = 'hello';

需要注意,const 上下文不會將表達式轉換爲完全不可變的。來看例子:

const arr = ['/about''/contacts'];

// 相當於 const Pages: {readonly home: '/', menu: string[]}
export const Pages = {
  home: '/',
  menu: arr,
} as const;

Pages.menu.push('/test'); // ✅

這裏,menu 屬性引用了一個外部數組,我們可以更改其內容。如果在對象上就地定義了數組,我們將無法更改其內容。

// 相當於 const Pages: {readonly home: '/'readonly menu: string[]}
export const Pages = {
  home: '/',
  menu: ['/about'],
} as const;

// 類型“readonly ["/about"]”上不存在屬性“push”。
Pages.menu.push('/test');

(5)非空斷言

在 TypeScript 中感嘆號 ( ! ) 運算符可以使編譯器忽略一些錯誤,下面就來看看它有哪些實際的用途的以及何時使用。

① 非空斷言運算符

感嘆號運算符稱爲非空斷言運算符,添加此運算符會使編譯器忽略undefinednull類型。來看例子:

const parseValue = (value: string) ={
    // ...
};

const prepareValue = (value?: string) ={
    // ...
    parseValue(value);
};

對於 prepareValue 方法的 value 參數,TypeScript 就會報出以下錯誤:

類型“string | undefined”的參數不能賦給類型“string”的參數。
不能將類型“undefined”分配給類型“string”。

因爲我們希望 prepareValue 函數中的 valueundefinedstring,但是我們將它傳遞給了 parseValue 函數,它的參數只能是 string。所以就報了這個錯誤。

但是,在某些情況下,我們可以確定 value 不會是 undefined,而這就是需要非空斷言運算符的情況:

const parseValue = (value: string) ={
  // ...
};

const prepareValue = (value?: string) ={
  // ...
  parseValue(value!);
};

這樣就不會報錯了。但是,在使用它時應該非常小心,因爲如果 value 的值是undefined ,它可能會導致意外的錯誤。

② 使用示例

既然知道了非空斷言運算符,下面就來看幾個真實的例子。

在列表中搜索是否存在某個項目:

interface Config {
  id: number;
  path: string;
}

const configs: Config[] = [
  {
    id: 1,
    path: "path/to/config/1",
  },
  {
    id: 2,
    path: "path/to/config/2",
  },
];

const getConfig = (id: number) ={
  return configs.find((config) => config.id === id);
};

const config = getConfig(1);

由於搜索的內容不一定存在於列表中,所以 config 變量的類型是 Config | undefined,我們就可以使用可以使用費控斷言運算符告訴 TypeScript,config 應該是存在的,因此不必假設它是 undefined

const getConfig = (id: number) ={
  return configs.find((config) => config.id === id)!;
};

const config = getConfig(1);

這時,config 變量的類型就是 Config。這時再從 config 中獲取任何屬性時,就不需要再檢查它是否存在了。

再來看一個例子,React 中的 Refs 提供了一種訪問 DOM 節點或 React 元素的方法:

const App = () ={
  const ref = useRef<HTMLDivElement>(null);

  const handleClick = () ={
    if(ref.current) {
      console.log(ref.current.getBoundingClientRect());
    }
  };

  return (
    <div class ref={ref}>
      <button onClick={handleClick}>Click</button>
    </div>
  );
};

這裏創建了一個簡單的組件,它可以訪問 class 爲 App 的 DOM 節點。組件中有一個按鈕,當點擊該按鈕時,會顯示元素的大小以及其在視口中的位置。我們可以確定被訪問的元素是在點擊按鈕後掛載的,所以可以在 TypeScript 中添加非空斷言運算符表示這個元素是一定存在的:

const App = () ={
  const handleClick = () ={
    console.log(ref.current!.getBoundingClientRect());
  };
};

當使用非空斷言運算符時,就表示告訴 TypeScript,我比你更瞭解這個代碼邏輯,會爲此負責,所以我們需要充分了解自己的代碼之後再確定是否要使用這個運算符。否則,如果由於某種原因斷言不正確,則會發生運行時錯誤。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KDE8B0ylT4OpdqoVymisFA