Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

TypeScript 類型基礎

原始類型#

const name: string = 'linbudu';
const age: number = 24;
const male: boolean = false;
const undef: undefined = undefined;
const nul: null = null;
const obj: object = { name, age, male };
const bigintVar1: bigint = 9007199254740991n;
const bigintVar2: bigint = BigInt(9007199254740991);
const symbolVar: symbol = Symbol('unique'); 

null 與 undefined#

在 JavaScript 中,null 與 undefined 分別表示 “這裡有值,但是個空值” 和 “這裡沒有值”。而在 TypeScript 中,null 與 undefined 類型都是有具體意義的類型。也就是說,它們作為類型時,表示的是一個有意義的具體類型值。這兩者在沒有開啟 strictNullChecks 檢查的情況下,會被視作其他類型的子類型,比如 string 類型會被認為包含了 null 與 undefined 類型:

const tmp1: null = null;
const tmp2: undefined = undefined;

const tmp3: string = null; // 僅在關閉 strictNullChecks 時成立,下同
const tmp4: string = undefined;

void#

TypeScript 的原始類型標註中也有 void,但與 JavaScript 中不同的是,這裡的 void 用於描述一個內部沒有 return 語句,或者沒有顯式 return 一個值的函數的返回值,如:

function func1() {}
function func2() {
  return;
}
function func3() {
  return undefined;
}

在這裡,func1 與 func2 的返回值類型都會被隱式推導為 void,只有顯式返回了 undefined 值的 func3 其返回值類型才被推導為 undefined。但在實際的代碼執行中,func1 與 func2 的返回值均是 undefined。

雖然 func3 的返回值類型會被推導為 undefined,但是你仍然可以使用 void 類型進行標註,因為在類型層面 func1、func2、func3 都表示 “沒有返回一個有意義的值”。

這裡可能有點繞,你可以認為 void 表示一個空類型,而 null 與 undefined 都是一個具有意義的實際類型(注意與它們在 JavaScript 中的意義區分)。而 undefined 能夠被賦值給 void 類型的變量,就像在 JavaScript 中一個沒有返回值的函數會默認返回一個 undefined。null 類型也可以,但需要在關閉 strictNullChecks 配置的情況下才能成立。

const voidVar1: void = undefined;
const voidVar2: void = null; // 需要關閉 strictNullChecks

陣列類型#

陣列同樣是我們最常用的類型之一,在 TypeScript 中有兩種方式來聲明一個陣列類型:

const arr1: string[] = [];
const arr2: Array<string> = [];

這兩種方式是完全等價的,但其實更多是以前者為主,如果你將鼠標懸浮在 arr2 上,會發現它顯示的類型簽名是 string[]。陣列是我們在日常開發大量使用的數據結構,但在某些情況下,使用 元組(Tuple) 來代替陣列要更加妥當,比如一個陣列中只存放固定長度的變量,並希望在越界訪問時給出類型報錯。

const arr4: [string, string, string] = ['lin', 'bu', 'du'];

console.log(arr4[599]);

此時將會產生一個類型錯誤:長度為 “3” 的元組類型 “[string, string, string]” 在索引 “599“處沒有元素。除了同類型的元素以外,元組內部也可以聲明多個與其位置強綁定的,不同類型的元素:

const arr5: [string, number, boolean] = ['linbudu', 599, true];

在這種情況下,對陣列合法邊界內的索引訪問(即 0、1、2)將精確地獲得對應位置上的類型。同時元組也支持了在某一個位置上的可選成員:

const arr6: [string, number?, boolean?] = ['linbudu'];
// 下面這麼寫也可以
// const arr6: [string, number?, boolean?] = ['linbudu', , ,];

對於標記為可選的成員,在 --strictNullCheckes 配置下會被視為一個 string | undefined 的類型。此時元組的長度屬性也會發生變化,比如上面的元組 arr6,其長度的類型為 1 | 2 | 3

type TupleLength = typeof arr6.length; // 1 | 2 | 3

你可能會覺得,元組的可讀性實際上並不好。比如對於 [string, number, boolean] 來說,你並不能直接知道這三個元素都代表什麼,還不如使用對象的形式。而在 TypeScript 4.0 中,有了具名元組(Labeled Tuple Elements)的支持,使得我們可以為元組中的元素打上類似屬性的標記:

const arr7: [name: string, age: number, male?: boolean] = ['linbudu', 599, true];

對於元組,隱式的越界訪問也能夠被揪出來給一個警告:

const arr5: [string, number, boolean] = ['linbudu', 599, true];

// 長度為 "3" 的元組類型 "[string, number, boolean]" 在索引 "3" 處沒有元素。
const [name, age, male, other] = arr5;

對象類型#

  • 每一個屬性的值必須一一對應到接口的屬性類型
  • 不能有多的屬性,也不能有少的屬性,包括直接在對象內部聲明,或是 obj1.other = 'xxx' 這樣屬性訪問賦值的形式

可選(Optional)& 只讀(Readonly)#

interface IDescription {
  name: string;
  age: number;
  male?: boolean;
  func?: Function;
}

const obj2: IDescription = {
  name: 'linbudu',
  age: 599,
  male: true,
  // 無需實現 func 也是合法的
};
interface IDescription {
  readonly name: string;
  age: number;
}

const obj3: IDescription = {
  name: 'linbudu',
  age: 599,
};

// 無法分配到 "name" ,因為它是只讀屬性
obj3.name = "林不渡";

其實在陣列與元組層面也有著只讀的修飾,但與對象類型有著兩處不同。

  • 你只能將整個陣列 / 元組標記為只讀,而不能像對象那樣標記某個屬性為只讀。
  • 一旦被標記為只讀,那這個只讀陣列 / 元組的類型上,將不再具有 push、pop 等方法(即會修改原陣列的方法),因此報錯信息也將是類型 xxx 上不存在屬性 “push” 這種。這一實現的本質是只讀陣列與只讀元組的類型實際上變成了 ReadonlyArray,而不再是 Array

type 與 interface#

interface 用來描述對象、類的結構,而類型別名 type 用來將一個函數簽名、一組聯合類型、一個工具類型等等抽離成一個完整獨立的類型

object、Object 以及 { }#

Object 包含了所有的類型:

// 對於 undefined、null、void 0 ,需要關閉 strictNullChecks
const tmp1: Object = undefined;
const tmp2: Object = null;
const tmp3: Object = void 0;

const tmp4: Object = 'linbudu';
const tmp5: Object = 599;
const tmp6: Object = { name: 'linbudu' };
const tmp7: Object = () => {};
const tmp8: Object = [];

和 Object 類似的還有 Boolean、Number、String、Symbol,這幾個裝箱類型(Boxed Types) 同樣包含了一些超出預期的類型。以 String 為例,它同樣包括 undefined、null、void,以及代表的 拆箱類型(Unboxed Types) string,但並不包括其他裝箱類型對應的拆箱類型,如 boolean 與 基本對象類型,我們看以下的代碼:

const tmp9: String = undefined;
const tmp10: String = null;
const tmp11: String = void 0;
const tmp12: String = 'linbudu';

// 以下不成立,因為不是字符串類型的拆箱類型
const tmp13: String = 599; // X
const tmp14: String = { name: 'linbudu' }; // X
const tmp15: String = () => {}; // X
const tmp16: String = []; // X

在任何情況下,你都不應該使用這些裝箱類型。

object 的引入就是為了解決對 Object 類型的錯誤使用,它代表所有非原始類型的類型,即陣列、對象與函數類型這些

const tmp17: object = undefined;
const tmp18: object = null;
const tmp19: object = void 0;

const tmp20: object = 'linbudu';  // X 不成立,值為原始類型
const tmp21: object = 599; // X 不成立,值為原始類型

const tmp22: object = { name: 'linbudu' };
const tmp23: object = () => {};
const tmp24: object = [];

最後是{},一個奇奇怪怪的空對象,如果你了解過字面量類型,可以認為{}就是一個對象字面量類型(對應到字符串字面量類型這樣)。否則,你可以認為使用{}作為類型簽名就是一個合法的,但內部無屬性定義的空對象,這類似於 Object(想想 new Object()),它意味著任何非 null /undefined 的值:

const tmp25: {} = undefined; // 僅在關閉 strictNullChecks 時成立,下同
const tmp26: {} = null;
const tmp27: {} = void 0; // void 0 等價於 undefined

const tmp28: {} = 'linbudu';
const tmp29: {} = 599;
const tmp30: {} = { name: 'linbudu' };
const tmp31: {} = () => {};
const tmp32: {} = [];

雖然能夠將其作為變量的類型,但你實際上無法對這個變量進行任何賦值操作

const tmp30: {} = { name: 'linbudu' };
tmp30.age = 18; // X 類型“{}”上不存在屬性“age”。

我們同樣要避免使用 {}。{} 意味著任何非 null /undefined 的值,從這個層面上看,使用它和使用 any 一樣惡劣。

當你不確定某個變量的具體類型,但能確定它不是原始類型,可以使用 object。但我更推薦進一步區分,也就是使用 Record<string, unknown>Record<string, any> 表示對象,unknown[]any[] 表示陣列,(...args: any[]) => any表示函數這樣。

字面量類型(Literal Types)#

它代表著比原始類型更精確的類型,同時也是原始類型的子類型

字面量類型主要包括字符串字面量類型數字字面量類型布爾字面量類型對象字面量類型,它們可以直接作為類型標註:

const str: "linbudu" = "linbudu";
const num: 599 = 599;
const bool: true = true;

// 報錯!不能將類型“"linbudu599"”分配給類型“"linbudu"”。
const str1: "linbudu" = "linbudu599";

const str2: string = "linbudu";
const str3: string = "linbudu599";

聯合類型#

而聯合類型你可以理解為,它代表了一組類型的可用集合,只要最終賦值的類型屬於聯合類型的成員之一,就可以認為符合這個聯合類型。

interface Tmp {
  bool: true | false;
  num: 1 | 2 | 3;
  str: "lin" | "bu" | "du"
  mixed: true | string | 599 | {} | (() => {}) | (1 | 2)
}
  • 對於聯合類型中的函數類型,需要使用括號()包裹起來
  • 函數類型並不存在字面量類型,因此這裡的 (() => {}) 就是一個合法的函數類型
  • 你可以在聯合類型中進一步嵌套聯合類型,但這些嵌套的聯合類型最終都會被展平到第一级中

聯合類型的常用場景之一是通過多個對象類型的聯合,來實現手動的互斥屬性,即這一屬性如果有字段 1,那就沒有字段 2:

interface Tmp {
  user:
    | {
        vip: true;
        expires: string;
      }
    | {
        vip: false;
        promotion: string;
      };
}

declare var tmp: Tmp;

if (tmp.user.vip) {
  console.log(tmp.user.expires);
}

在這個例子中,user 屬性會滿足普通用戶與 VIP 用戶兩種類型,這裡 vip 屬性的類型基於布爾字面量類型聲明。我們在實際使用時可以通過判斷此屬性為 true,確保接下來的類型推導都會將其類型收窄到 VIP 用戶的類型(即聯合類型的第一個分支)

我們也可以通過類型別名來復用一組字面量聯合類型:

type Code = 10000 | 10001 | 50000;
type Status = "success" | "failure";

枚舉類型#

enum PageUrl {
  Home_Page_Url = "url1",
  Setting_Page_Url = "url2",
  Share_Page_Url = "url3",
}

const home = PageUrl.Home_Page_Url;

這麼做的好處非常明顯。首先,你擁有了更好的類型提示。其次,這些常量被真正地約束在一個命名空間下(上面的對象聲明總是差點意思)。如果你沒有聲明枚舉的值,它會默認使用數字枚舉,並且從 0 開始,以 1 遞增

如果你只為某一個成員指定了枚舉值,那麼之前未賦值成員仍然會使用從 0 遞增的方式,之後的成員則會開始從枚舉值遞增。

enum Items {
  // 0 
  Foo,
  Bar = 599,
  // 600
  Baz
}

在數字型枚舉中,你可以使用延遲求值的枚舉值,比如函數:

const returnNum = () => 100 + 499;

enum Items {
  Foo = returnNum(),
  Bar = 599,
  Baz
}

但要注意,延遲求值的枚舉值是有條件的。如果你使用了延遲求值,那麼沒有使用延遲求值的枚舉成員必須放在使用常量枚舉值聲明的成員之後(如上例),或者放在第一位

enum Items {
  Baz,
  Foo = returnNum(),
  Bar = 599,
}

TypeScript 中也可以同時使用字符串枚舉值和數字枚舉值:

enum Mixed {
  Num = 599,
  Str = "linbudu"
}

枚舉和對象的重要差異在於,對象是單向映射的,我們只能從鍵映射到鍵值。而枚舉是雙向映射的,即你可以從枚舉成員映射到枚舉值,也可以從枚舉值映射到枚舉成員:

enum Items {
  Foo,
  Bar,
  Baz
}

const fooValue = Items.Foo; // 0
const fooKey = Items[0]; // "Foo"

但需要注意的是,僅有值為數字的枚舉成員才能夠進行這樣的雙向枚舉,字符串枚舉成員仍然只會進行單次映射

常量枚舉#

const enum Items {
  Foo,
  Bar,
  Baz
}

const fooValue = Items.Foo; // 0

它和普通枚舉的差異主要在訪問性與編譯產物。對於常量枚舉,你只能通過枚舉成員訪問枚舉值(而不能通過值訪問成員)

函數#

函數的類型簽名#

函數的類型就是描述了函數入參類型與函數返回值類型

function foo(name: string): number {
  return name.length;
}

const foo = function (name: string): number {
  return name.length
}

const foo: (name: string) => number = function (name) {
  return name.length
}

const foo = (name: string): number => {
  return name.length
}

const foo: (name: string) => number = (name) => {
  return name.length
}

要麼直接在函數中進行參數和返回值的類型聲明,要麼使用類型別名將函數聲明抽離出來

type FuncFoo = (name: string) => number

const foo: FuncFoo = (name) => {
  return name.length
}

如果只是為了描述這個函數的類型結構,我們甚至可以使用 interface 來進行函數聲明:

interface FuncFooStruct {
  (name: string): number
}

這時的 interface 被稱為 Callable Interface,看起來可能很奇怪,但我們可以這麼認為,interface 就是用來描述一個類型結構的,而函數類型本質上也是一個結構固定的類型罷了。

可選參數與 rest 參數#

// 在函數邏輯中注入可選參數默認值
function foo1(name: string, age?: number): number {
  const inputAge = age || 18; // 或使用 age ?? 18
  return name.length + inputAge
}

// 直接為可選參數聲明默認值
function foo2(name: string, age: number = 18): number {
  const inputAge = age;
  return name.length + inputAge
}

需要注意的是,可選參數必須位於必選參數之後。畢竟在 JavaScript 中函數的入參是按照位置(形參),而不是按照參數名(名參)進行傳遞。

對於 rest 參數的類型標註也比較簡單,由於其實際上是一個陣列,這裡我們也應當使用陣列類型或元組類型進行標註:

function foo(arg1: string, ...rest: any[]) { }
function foo(arg1: string, ...rest: [number, boolean]) { }

重載#

要想實現與入參關聯的返回值類型,我們可以使用 TypeScript 提供的函數重載簽名(Overload Signature)

function func(foo: number, bar: true): string;
function func(foo: number, bar?: false): number;
function func(foo: number, bar?: boolean): string | number {
  if (bar) {
    return String(foo);
  } else {
    return foo * 599;
  }
}

const res1 = func(599); // number
const res2 = func(599, true); // string
const res3 = func(599, false); // number

這裡有一個需要注意的地方,擁有多個重載聲明的函數在被調用時,是按照重載的聲明順序往下查找的。因此在第一個重載聲明中,為了與邏輯中保持一致,即在 bar 為 true 時返回 string 類型,這裡我們需要將第一個重載聲明的 bar 聲明為必選的字面量類型。

實際上,TypeScript 中的重載更像是偽重載,它只有一個具體實現,其重載體現在方法調用的簽名上而非具體實現上。而在如 C++ 等語言中,重載體現在多個名稱一致但入參不同的函數實現上,這才是更廣義上的函數重載。

異步函數#

對於異步函數(即標記為 async 的函數),其返回值必定為一個 Promise 類型,而 Promise 內部包含的類型則通過泛型的形式書寫,即 Promise<T>

async function asyncFunc(): Promise<void> {}

Class#

類與類成員的類型簽名#

一個函數的主要結構即是參數、邏輯和返回值,對於邏輯的類型標註其實就是對普通代碼的標註,所以我們只介紹了對參數以及返回值的類型標註。而到了 Class 中其實也一樣,它的主要結構只有構造函數、屬性、方法和訪問符(Accessor)

class Foo {
  prop: string;

  constructor(inputProp: string) {
    this.prop = inputProp;
  }

  print(addon: string): void {
    console.log(`${this.prop} and ${addon}`)
  }

  get propA(): string {
    return `${this.prop}+A`;
  }

  set propA(value: string) {
    this.prop = `${value}+A`
  }
}

唯一需要注意的是,setter 方法不允許進行返回值的類型標註,你可以理解為 setter 的返回值並不會被消費,它是一個只關注過程的函數。類的方法同樣可以進行函數那樣的重載,且語法基本一致,這裡我們不再贅述。

修飾符#

在 TypeScript 中我們能夠為 Class 成員添加這些修飾符:public / private / protected / readonly。除 readonly 以外,其他三位都屬於訪問性修飾符,而 readonly 屬於操作性修飾符(就和 interface 中的 readonly 意義一致)。當你不顯式使用訪問性修飾符,成員的訪問性默認會被標記為 public。

  • public:此類成員在類、類的實例、子類中都能被訪問。
  • private:此類成員僅能在類的內部被訪問。
  • protected:此類成員僅能在類與子類中被訪問,你可以將類和類的實例當成兩種概念,即一旦實例化完畢(出廠零件),那就和類(工廠)沒關係了,即不允許再訪問受保護的成員。
class Foo {
  private prop: string;

  constructor(inputProp: string) {
    this.prop = inputProp;
  }

  protected print(addon: string): void {
    console.log(`${this.prop} and ${addon}`)
  }

  public get propA(): string {
    return `${this.prop}+A`;
  }

  public set propA(value: string) {
    this.propA = `${value}+A`
  }
}

我們通常不會為構造函數添加修飾符,而是讓它保持默認的 public

簡單起見,我們可以在構造函數中對參數應用訪問性修飾符:

class Foo {
  constructor(public arg1: string, private arg2: boolean) { }
}

new Foo("linbudu", true)

靜態成員#

你可以使用 static 關鍵字來標識一個成員為靜態成員:

class Foo {
  static staticHandler() { }

  public instanceHandler() { }
}

不同於實例成員,在類的內部靜態成員無法通過 this 來訪問,需要通過 Foo.staticHandler 這種形式進行訪問。
靜態成員不會被實例繼承,它始終只屬於目前定義的這個類(以及其子類)

繼承、實現、抽象類#

class Base { }

class Derived extends Base { }

基類中的哪些成員能夠被派生類訪問,完全是由其訪問性修飾符決定的。我們在上面其實已經介紹過,派生類中可以訪問到使用 publicprotected 修飾符的基類成員。除了訪問以外,基類中的方法也可以在派生類中被覆蓋,但我們仍然可以通過 super 訪問到基類中的方法:

class Base {
  print() { }
}

class Derived extends Base {
  print() {
    super.print()
    // ...
  }
}

在派生類中覆蓋基類方法時,我們並不能確保派生類的這一方法能覆蓋基類方法,萬一基類中不存在這個方法呢?所以,TypeScript 4.3 新增了 override 關鍵字,來確保派生類嘗試覆蓋的方法一定在基類中存在定義:

class Base {
  printWithLove() { }
}

class Derived extends Base {
  override print() {
    // ...
  }
}

除了基類與派生類以外,還有一個比較重要的概念:抽象類。抽象類是對類結構與方法的抽象,簡單來說,一個抽象類描述了一個類中應當有哪些成員(屬性、方法等),一個抽象方法描述了這一方法在實際實現中的結構。我們知道類的方法和函數非常相似,包括結構,因此抽象方法其實描述的就是這個方法的入參類型返回值類型

abstract class AbsFoo {
  abstract absProp: string;
  abstract get absGetter(): string;
  abstract absMethod(name: string): string
}
class Foo implements AbsFoo {
  absProp: string = "linbudu"

  get absGetter() {
    return "linbudu"
  }

  absMethod(name: string) {
    return name
  }
}

此時,我們必須完全實現這個抽象類的每一個抽象成員。需要注意的是,在 TypeScript 中無法聲明靜態的抽象成員

interface 不僅可以聲明函數結構,也可以聲明類的結構:

interface FooStruct {
  absProp: string;
  get absGetter(): string;
  absMethod(input: string): string
}

class Foo implements FooStruct {
  absProp: string = "linbudu"

  get absGetter() {
    return "linbudu"
  }

  absMethod(name: string) {
    return name
  }
}

除此以外,我們還可以使用 Newable Interface 來描述一個類的構造(類似於描述函數結構的 Callable Interface):

class Foo { }

interface FooStruct {
  new(): Foo
}

declare const NewableFoo: FooStruct;

const foo = new NewableFoo();

any 類型#

一個被標記為 any 類型的參數可以接受任意類型的值,它可以在聲明後再次接受任意類型的值,同時可以被賦值給任意其它類型的變量:

// 被標記為 any 類型的變量可以擁有任意類型的值
let anyVar: any = "linbudu";

anyVar = false;
anyVar = "linbudu";
anyVar = {
  site: "juejin"
};

anyVar = () => { }

// 標記為具體類型的變量也可以接受任何 any 類型的值
const val1: string = anyVar;
const val2: number = anyVar;
const val3: () => {} = anyVar;
const val4: {} = anyVar;

你可以在 any 類型變量上任意地進行操作,包括賦值、訪問、方法調用等等,此時可以認為類型推導與檢查是被完全禁用的:

let anyVar: any = null;

anyVar.foo.bar.baz();
anyVar[0][1][2].prop1;

而 any 類型的主要意義,其實就是為了表示一個無拘無束的 “任意類型”,它能兼容所有類型,也能夠被所有類型兼容
any 的本質是類型系統中的頂級類型,即 Top Type

  • 如果是類型不兼容報錯導致你使用 any,考慮用類型斷言替代
  • 如果是類型太複雜導致你不想全部聲明而使用 any,考慮將這一處的類型去斷言為你需要的最簡類型。如你需要調用 foo.bar.baz(),就可以先將 foo 斷言為一個具有 bar 方法的類型。
  • 如果你是想表達一個未知類型,更合理的方式是使用 unknown

unknown 類型#

一個 unknown 類型的變量可以再次賦值為任意其它類型,但只能賦值給 any 與 unknown 類型的變量:

let unknownVar: unknown = "linbudu";

unknownVar = false;
unknownVar = "linbudu";
unknownVar = {
  site: "juejin"
};

unknownVar = () => { }

const val1: string = unknownVar; // Error
const val2: number = unknownVar; // Error
const val3: () => {} = unknownVar; // Error
const val4: {} = unknownVar; // Error

const val5: any = unknownVar;
const val6: unknown = unknownVar;

unknown 和 any 的一個主要差異體現在賦值給別的變量時,any 就像是 “我身化萬千無處不在” ,所有類型都把它當自己人。而 unknown 就像是 “我雖然身化萬千,但我堅信我在未來的某一刻會得到一個確定的類型” ,只有 any 和 unknown 自己把它當自己人。簡單地說,any 放棄了所有的類型檢查,而 unknown 並沒有。這一點也體現在對 unknown 類型的變量進行屬性訪問時:

let unknownVar: unknown;
unknownVar.foo(); // 報錯:對象類型為 unknown

要對 unknown 類型進行屬性訪問,需要進行類型斷言,即 “雖然這是一個未知的類型,但我跟你保證它在這裡就是這個類型!”:

let unknownVar: unknown;
(unknownVar as { foo: () => {} }).foo();

在類型未知的情況下,更推薦使用 unknown 標註。這相當於你使用額外的心智負擔保證了類型在各處的結構,後續重構為具體類型時也可以獲得最初始的類型信息,同時還保證了類型檢查的存在。

never 類型#

never 類型不攜帶任何的類型信息,因此會在聯合類型中被直接移除

declare let v1: never;
declare let v2: void;

v1 = v2; // X 類型 void 不能賦值給類型 never
v2 = v1;

在編程語言的類型系統中,never 類型被稱為 Bottom Type,是整個類型系統層級中最底層的類型。和 null、undefined 一樣,它是所有類型的子類型,但只有 never 類型的變量能夠賦值給另一個 never 類型變量。

通常我們不會顯式地聲明一個 never 類型,它主要被類型檢查所使用。但在某些情況下使用 never 確實是符合邏輯的,比如一個只負責拋出錯誤的函數:

function justThrow(): never {
  throw new Error()
}

在類型流的分析中,一旦一個返回值類型為 never 的函數被調用,那麼下方的代碼都會被視為無效的代碼(即無法執行到):

function foo (input:number){
  if(input > 1){
    justThrow();
    // 等同於 return 語句後的代碼,即 Dead Code
    const name = "linbudu";
  }
}

我們也可以顯式利用它來進行類型檢查,即上面在聯合類型中 never 類型神秘消失的原因。

if (typeof strOrNumOrBool === "string") {
    // 一定是字符串!
  strOrNumOrBool.charAt(1);
} else if (typeof strOrNumOrBool === "number") {
  strOrNumOrBool.toFixed();
} else if (typeof strOrNumOrBool === "boolean") {
  strOrNumOrBool === true;
} else {
  const _exhaustiveCheck: never = strOrNumOrBool;
  throw new Error(`Unknown input type: ${_exhaustiveCheck}`);
}

假設某個粗心的同事新增了一个類型分支,strOrNumOrBool 變成了 strOrNumOrBoolOrFunc,卻忘記新增對應的處理分支,此時在 else 代碼塊中就會出現將 Function 類型賦值給 never 類型變量的類型錯誤。這實際上就是利用了類型分析能力與 never 類型只能賦值給 never 類型這一點,來確保聯合類型變量被妥善處理。

類型斷言#

類型斷言能夠顯式告知類型檢查程序當前這個變量的類型,可以進行類型分析地修正、類型。它其實就是一個將變量的已有類型更改為新指定類型的操作,它的基本語法是 as NewType,你可以將 any /unknown 類型斷言到一個具體的類型:

const str: string = "linbudu";
(str as any).func().foo().prop;

function foo(union: string | number) {
  if ((union as string).includes("linbudu")) { }
  if ((union as number).toFixed() === '599') { }
}

但是類型斷言的正確使用方式是,在 TypeScript 類型分析不正確或不符合預期時,將其斷言為此處的正確類型:

interface IFoo {
  name: string;
}

declare const obj: {
  foo: IFoo
}

const {
  foo = {} as IFoo
} = obj

雙重斷言#

你的斷言類型和原類型的差異太大,需要先斷言到一個通用的類,即 any /unknown。這一通用類型包含了所有可能的類型,因此斷言到它從它斷言到另一個類型差異不大。

const str: string = "linbudu";

(str as unknown as { handler: () => {} }).handler();

// 使用尖括號斷言
(<{ handler: () => {} }>(<unknown>str)).handler();

非空斷言#

非空斷言其實是類型斷言的簡化,它使用 ! 語法,即 obj!.func()!.prop 的形式標記前面的一个聲明一定是非空的(實際上就是剔除了 null 和 undefined 類型),比如這個例子:

declare const foo: {
  func?: () => ({
    prop?: number | null;
  })
};

foo.func!().prop!.toFixed();

非空斷言的常見場景還有 document.querySelectorArray.find 方法等:

const element = document.querySelector("#id")!;
const target = [1, 2, 3, 599].find(item => item === 599)!;

類型斷言還有一種用法是作為代碼提示的輔助工具,比如對於以下這個稍微複雜的接口:

interface IStruct {
  foo: string;
  bar: {
    barPropA: string;
    barPropB: number;
    barMethod: () => void;
    baz: {
      handler: () => Promise<void>;
    };
  };
}

假設你想要基於這個結構隨便實現一個對象,你可能會使用類型標註:

const obj: IStruct = {};

這個時候等待你的是一堆類型報錯,你必須規規矩矩地實現整個接口結構才可以。但如果使用類型斷言,我們可以在保留類型提示的前提下,不那麼完整地實現這個結構:

// 這個例子是不會報錯的
const obj = <IStruct>{
  bar: {
    baz: {},
  },
};

References:
https://juejin.cn/book/7086408430491172901

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。