帶你聊聊typeScript中的extends關鍵字

2023-02-13 22:00:28

extends 是 typeScript 中的關鍵字。在 typeScript 的型別程式設計世界裡面,它所扮演的角色實在是太重要了,所以,我們不得不需要重視它,深入學習它。在我看來,掌握它就是進入高階 typeScript 型別程式設計世界的敲門磚。但是,現實是,它在不同的上下文中,具體不同的,相差很大的語意。如果沒有深入地對此進行梳理,它會給開發者帶來很大的困惑。梳理並深入學習它,最後掌握它,這就是我編寫這篇文章的初衷。

extends 的幾個語意

讓我們開門見山地說吧,在 typeScript 在不同的上下文中,extends 有以下幾個語意。不同語意即有不同的用途:

  • 用於表達型別組合;
  • 用於表達物件導向中「類」的繼承
  • 用於表達泛型的型別約束;
  • 在條件型別(conditional type)中,充當型別表示式,用於求值。

extends 與 型別組合/類繼承

extends 可以跟 interface 結合起來使用,用於表達型別組合。

範例 1-1

interface ChildComponentProps {
    onChange: (val: string)=> void
}

interface ParentComponentProps extends ChildComponentProps {
    value: string
}
登入後複製

在 react 元件化開發模式中,存在一種自底向上的構建模式 - 我們往往會先把所有最底層的子元件的 props 構建好,最後才定義 container component(負責提升公共 state,聚合和分發 props) 的 props。此時,inferface 的 extends 正好能表達這種語意需求 - 型別的組合(將所有子元件的 props 聚合到一塊)。

當然,interfaceextends 從句是可以跟著多個組合物件,多個組合物件之間用逗號,隔開。比如ParentComponentProps組合多個子元件的 props

範例 1-2

interface ChildComponentProps {
    onChange: (val: string)=> void
}

interface ChildComponentProps2 {
    onReset: (value: string)=> void
}

interface ParentComponentProps extends ChildComponentProps, ChildComponentProps2 {
    value: string
}
登入後複製

注意,上面指出的是「多個組合物件」,這裡也包括了Class。對,就是普通面向概念中的「類」。也就是說,下面的程式碼也是合法的:

範例 1-3

interface ChildComponentProps {
    onChange: (val: string)=> void
}

interface ChildComponentProps2 {
    onReset: (value: string)=> void
}

class SomeClass {
    private name!: string // 變數宣告時,變數名跟著一個感嘆號`!`,這是「賦值斷言」的語法
    updateName(name:string){
        this.name = name || ''
    }
}

interface ParentComponentProps extends
ChildComponentProps,
ChildComponentProps2,
SomeClass {
    value: string
}
登入後複製

之所以這也是合法的,一切源於一個特性:在 typeScript 中,一個 class 變數既是「值」也是「型別」。在interface extends class的上下文中,顯然是取 class 是「型別」的語意。一個 interface extends 另外一個 class,可以理解為 interface 拋棄這個 class 的所有實現程式碼,只是跟這個 class 的「型別 shape」 進行組合。還是上面的範例程式碼中,從型別 shape 的角度,SomeClass 就等同於下面的 interface:

範例 1-4

interface SomeClass {
   name: string
   updateName: (name:string)=> void
}
登入後複製

好了,以上就是 extends 關鍵字的「型別組合」的語意。事情開始發生了轉折。

如果某個 interface A 繼承了某個 class B,那麼這個 interface A 還是能夠被其他 interface 去繼承(或者說組合)。但是,如果某個 class 想要 implements 這個 interface A,那麼這個 class 只能是 class B 本身或者 class B 的子類。

範例 1-5

class Control {
   private state: any;
  constructor(intialValue: number){
    if(intialValue > 10){
      this.state = false
    }else {
      this.state = true
    }
  }
  checkState(){
    return this.state;
  }
}

interface SelectableControl extends Control {
  select(): void;
}

// 下面的程式碼會報錯:Class 'DropDownControl' incorrectly implements interface
// 'SelectableControl'.
// Types have separate declarations of a private property 'state'.(2420)
class DropDownControl  implements SelectableControl {
  private state = false;
  checkState(){
    // do something
  }
  select(){
    // do something
  }
}
登入後複製

要想解決這個問題,class DropDownControl必須要繼承 Control class 或者Control class 的子類:

範例 1-6

class Control {
   private state: any;
  constructor(intialValue: number){
    if(intialValue > 10){
      this.state = false
    }else {
      this.state = true
    }
  }
  checkState(){
    return this.state;
  }
}

interface SelectableControl extends Control {
  select(): void;
}

// 下面的程式碼就不會報錯,且能得到預期的執行結果
class DropDownControl  extends Control  implements SelectableControl {
  // private state = false;
  //checkState(){
    // do something
  //}
  select(){
    // do something
  }
}

const dropDown = new DropDownControl(1);
dropDown.checkState(); // Ok
dropDown.select(); // Ok
登入後複製

上面這個範例程式碼扯出了 extends 關鍵字的另外一個語意 - 「繼承」。當extends用於 typeScript 的類之間,它的準確語意也就是 ES6 中物件導向中「extends」關鍵字的語意。AClass extends BClass 不再應該解讀為「型別的組合」而是物件導向程式設計中的「AClass 繼承 BClass」和「AClass 是父類別 BClass 的子類」。與此同時,值得指出的是,此時的 extends 關鍵字是活在了「值的世界」, 遵循著 ES6 中 extends關鍵字一樣的語意。比較顯著的一點就是,ts 中的 extends 也是不能在同一時間去繼承多個父類別的。比如,下面的程式碼就會報錯:

範例 1-7

class A {}
class B {}
// 報錯: Classes can only extend a single class.(1174)
class C extends A,B {

}
登入後複製

關於具有「繼承」語意的 extends 更多行為特性的闡述已經屬於物件導向程式設計正規化的範疇了,這裡就不深入討論了,有興趣的同學可以自行去了解。

至此,我們算是瞭解 extends 關鍵字跟 interfaceclass 結合起來所表達的兩種不同的語意:

  • 型別的組合
  • 物件導向概念中「類的繼承」

接下來,我們看看用於表達泛型型別約束的 extends

extends 與型別約束

更準確地說,這一節是要討論 extends 跟泛型形參結合時候的「型別約束」語意。在更進一步討論之前,我們不妨先複習一下,泛型形參宣告的語法以及我們可以在哪些地方可以宣告泛型形參。

具體的泛型形參宣告語法是:

  • 識別符號後面用尖括號<>包住一個或者多個泛型形參

  • 多個泛型形參用,號隔開

  • 泛型新參的名字可以隨意命名(我們見得最多就是使用單個英文字母TU之類的)。

在 typeScript 中,我們可以在以下地方去宣告一個泛型形參。

  • 在普通的函數宣告中:
    function dispatch<A>(action: A): A {
        // Do something
    }
    登入後複製
  • 在函數表示式形態的型別註解中:
    const dispatch: <A>(action: A)=> A =  (action)=> {
      return action
    }
    
    // 或者
    interface Store {
     dispatch: <A>(action: A)=> A
    }
    登入後複製
  • interface 的宣告中:
    interface Store<S> {
     dispatch: <A>(action: A)=> A
     reducer: <A>(state: S,action: A)=> S
    }
    登入後複製
  • class 的宣告中:
    class GenericAdd<AddableType> {
      zeroValue!: AddableType;
      add!: (x: AddableType, y: AddableType) => AddableType;
    }
    
    let myGenericNumber = new GenericNumber<number>();
    myGenericNumber.zeroValue = 0;
    myGenericNumber.add = function (x, y) {
        return x + y;
    };
    登入後複製
  • 在自定義型別宣告中:
     type Dispatch<A>=(action:A)=> A
    登入後複製
  • 在型別推導中:typeScript // 此處,F 和 Rest 就是泛型形參 type GetFirstLetter<S> = S extends `${infer F extends `${number}`}${infer Rest}` ? F : S; 以上就是簡單梳理後的可以產生泛型形參的地方,可能還有疏漏,但是這裡就不深入發掘了。

下面重點來了 - 凡是有泛型形參的地方,我們都可以通過 extends 來表達型別約束。這裡的型別約束展開說就是,泛型形參在範例化時傳進來的型別實參必須要滿足我們所宣告的型別約束。到這裡,問題就來了,我們該怎樣來理解這裡的「滿足」呢?在深究此問題之前,我們來看看型別約束的語法:

`泛型形參` extends `某個型別`
登入後複製

為了引出上面所說「滿足」的理解難題,我們不妨先看看下面的範例的程式碼:

範例 2-1

// case 1
type UselessType<T extends number> = T;
type Test1 = UselessType<any> // 這裡會報錯嗎?
type Test1_1 = UselessType<number|string> // 這裡會報錯嗎?

// case 2
type UselessType2<T extends {a:1, b:2}> = T;
type Test2 = UselessType2<{a:1, b:2, c:3}> // 這裡會報錯嗎?
type Test2_1 = UselessType2<{a:1}> // 這裡會報錯嗎?
type Test2_2 = UselessType2<{[key:string]: any}> // 這裡會報錯嗎?
type Test2_3 = {a:1, b:2} extends  {[key:string]: any} ? true : false

// case 3
class BaseClass {
    name!: string
}

class SubClass extends  BaseClass{
    sayHello!: (name: string)=> void
}

class SubClass2 extends  SubClass{
    logName!: ()=> void
}

type UselessType3<T extends SubClass> = T;
type Test3 = UselessType3<{name: '鯊叔'}> // 這裡會報錯嗎?
type Test3_1 = UselessType3<SubClass> // 這裡會報錯嗎?
type Test3_2 = UselessType3<BaseClass> // 這裡會報錯嗎?
登入後複製

不知道讀者朋友們在沒有把上述程式碼拷貝到 typeScript 的 playground 裡面去驗證之前你是否能全部猜中。如果能,證明你對 extends 在型別約束的語意上下文中的行為表現已經掌握的很清楚了。如果不能,請允許我為你娓娓道來。

相信有部分讀者瞭解過 typeScript 的型別系統的設計策略。由於 js 是一門動態弱型別的指令碼語言,再加上需要考慮 typeScript 與 js 的互操性和相容性。所以, typeScript 型別系統被設計為一個「structural typing」系統(結構化型別系統)。所謂的結構化型別系統的一個顯著的特點就是 - 具有某個型別 A 的值是否能夠賦值給另外一個型別 B 的值的依據是,型別 A 的型別結構是否跟型別 B 的型別結構是否相容。 而型別之間是否相容看重的型別的結構而不是型別的名字。再說白一點,就是 B 型別有的屬性和方法,你 A 型別也必須有。到這裡,就很容易引出一個廣為大眾接受的,用於理解型別「可賦值性」行為的心智模型,即:

  • 用集合的角度去看型別。故而這裡有「父集」和 「子集」的概念,「父集」包含 「子集」;

  • 在 typeScript 的型別系統中, 子集型別是可以賦值給父集型別。

  • 在泛型形參範例化時,如果 extends 前面的型別是它後面的型別的子集,那麼我們就說當前的範例化是「滿足」我們所宣告的型別約束的。

以下是 範例 2-1 的執行結果:

image.png

實際上,上面的那個心智模型是無法匹配到以上範例在 上的執行結果。以上面這個心智模型(子集型別能賦值給父集型別,反之則不然)來看範例的執行結果,我們會有下面的直覺認知偏差:

  • case 1 中,anynumber 的父集,為什麼它能賦值給 number 型別的值?
  • case 1 中,number | string 應該是 number 的父集,所以,它不能賦值給 number 型別的值。
  • case 1 中,number & string 應該是 number 的父集,按理說,這裡應該報錯,但是為什麼卻沒有?
  • case 2 中,{a:1}{a:1,b:2} 的子集,按理說,它能賦值給 {a:1,b:2}型別的值啊,為什麼會報錯?
  • case 3 中,感覺{name: '鯊叔'}SubClass 的子集,按理說,它能賦值給 SubClass型別的值啊,為什麼會報錯?
  • case 3 中,感覺BaseClassSubClass 的子集,按理說,它能賦值給 SubClass型別的值啊,為什麼會報錯?

經過反覆驗證和查閱資料,正確的認知如下:

  • case 1 中,any 是任何型別的子集,也是任何型別的父集。這裡 typeScript 往寬鬆方向去處理,即取 number 的子集之意;
  • number | string 之所以不能賦值給 number ,並不是因為 number | stringnumber 的父集,而是因為聯合型別遇到 extends關鍵字所產生的「分配律」的結果。即是因為 number|string extends number的結果等於 (number extend number) | (string extends number)的結果。顯然,(number string extends number的值是 false 的,所以,整個型別約束就不滿足;
  • 物件型別的型別不能採用 子集型別 extends 父集型別 = true的心智模型來理解。而是得采用 父集型別 extends 子集型別 = true。與此同時,當子集型別中有明確字面量 key-value 對的時候,父集型別中也必須需要有。否則的話,就是不可賦值給子集型別。
  • number & string 應該被視為物件型別的型別,遵循上面一條的規則。

基於上面的正確認知,我們不妨把我們的心智模型修正一下:

  • 應該使用「父類別型」和「子型別」的概念去理解滿足型別約束背後所遵循的規則;
  • 在型別約束 AType extends BType 中,如果 ATypeBType的子型別,那麼我們就會說 AType 是滿足我們所宣告的型別約束的;
  • 根據下面的 「ts 型別層級關係圖」來判斷兩種型別的父-子型別關係:

注:1)A -> B表示「A 是 B 的父類別型,B 是 A 的子型別」;2)strictNullChecks 編譯標誌位開啟後,undefined,voidnull就不會成為 typeScript 型別系統的一層,因為它們是不能賦值給其他型別的。

image.png

關於上面這張圖,有幾點可以單獨拿出來強調一下:

  • any 無處不在。它既是任何型別的子型別,也是任何型別的父類別型,甚至可能是任意型別自己。所以,它可以賦值給任何型別;
  • {} 充當 typeScript 型別的時候,它是有特殊含義的 - 它對應是(Object.prototype.__proto__)=null在 js 原型鏈上的地位,它被視為所有的物件型別的基礎類別。
  • array 的字面量形式的子型別就是tuple,function 的字面量形式的子型別就是函數表示式型別tuple函數表示式型別都被囊括到 字面量型別中去。

現在我們用這個新的心智模型去理解一下 範例 2-1 報錯的地方:

  • type Test1_1 = UselessType<number|string> 之所以報錯,是因為在型別約束中,如果 extends前面的型別是聯合型別,那麼要想滿足型別約束,則聯合型別的每一個成員都必須滿足型別約束才行。這就是所謂的「聯合型別的分配律」。顯然,string extends number 是不成立的,所以整個聯合型別就不滿足型別約束;
  • 對於物件型別的型別 - 即強調由屬性和方法所組成的集合型別,我們需要先用物件導向的概念來確定兩個型別中,誰是子類,誰是父類別。這裡的判斷方法是 - 如果 A 型別相比 B 型別多出了一些屬性/方法的話(這也同時意味著 B 型別擁有的屬性或者方法,A 型別也必須要有),那麼 A 型別就是父類別,B 型別就是子類。然後,我們再轉換到子型別和父類別型的概念上來 - 父類別就是「父類別型」,子類就是「子型別」。
    • type Test2_1 = UselessType2<{a:1}> 之所以報錯,是因為{a:1}{a:1, b:2}的父類別型,所以是不能賦值給{a:1, b:2}
    • {[key:string]: any}並不能成為 {a:1, b:2} 的子型別,因為,父類別型有的屬性/方法,子型別必須顯式地擁有。{[key:string]: any}沒有顯式地擁有,所以,它不是 {a:1, b:2}的子型別,而是它的父類別型。
    • type Test3 = UselessType3<{name: '鯊叔'}>type Test3_2 = UselessType3<BaseClass> 報錯的原因也是因為因為缺少了相應的屬性/方法,所以,它們都不是SubClass的子型別。

到這裡,我們算是剖析完畢。下面總結一下。

  • extends 緊跟在泛型形參後面時,它是在表達「型別約束」的語意;
  • AType extends BType 中,只有 ATypeBType 的子型別,ts 通過型別約束的檢驗;
  • 面對兩個 typeScript 型別,到底誰是誰的子型別,我們可以根據上面給出的 「ts 型別層級關係圖」來判斷。而對於一些充滿迷惑的邊緣用例,死記硬背即可。

extends 與條件型別

眾所周知,ts 中的條件型別就是 js 世界裡面的「三元表示式」。只不過,相比值世界裡面的三元表示式最終被計算出一個「值」,ts 的三元表示式最終計算出的是「型別」。下面,我們先來複習一下它的語法:

AType extends BType ?  CType :  DType
登入後複製

在這裡,extends 關鍵字出現在三元表達的第一個子句中。按照我們對 js 三元表示式的理解,我們對 typeScript 的三元表示式的理解應該是相似的:如果 AType extends BType 為邏輯真值,那麼整個表示式就返回 CType,否則的話就返回DType。作為過來人,只能說,大部分情況是這樣的,在幾個邊緣 case 裡面,ts 的表現讓你大跌眼鏡,後面會介紹。

跟 js 的三元表示式支援巢狀一樣,ts 的三元表示式也支援巢狀,即下面也是合法的語法:

AType extends BType ?  (CType extends DType ? EType : FType) : (GType extends HType ? IType : JType)
登入後複製

到這裡,我們已經看到了 typeScript 的型別程式設計世界的大門了。因為,三元表示式本質就是條件-分支語句,而後者就是邏輯編輯世界的最基本的要素了。而在我們進入 typeScript 的型別程式設計世界之前,我們首要搞清楚的是,AType extends BType何時是邏輯上的真值。

幸運的是,我們可以複用「extends 與型別約束」上面所產出的心智模型。簡而言之,如果 ATypeBType 的子型別,那麼程式碼執行就是進入第一個條件分支語句,否則就會進入第二個條件分支語句。

上面這句話再加上「ts 型別層級關係圖」,我們幾乎可以理解AType extends BType 99% 的語意。還剩下 1% 就是那些違背正常人直覺的特性表現。下面我們重點說說這 1% 的特性表現。

extends 與 {}

我們開門見山地問吧:「請說出下面程式碼的執行結果。」

type Test = 1 extends {} ? true : false // 請問 `Test` 型別的值是什麼?
登入後複製

如果你認真地去領會上面給出的「ts 型別層級關係圖」,我相信你已經知道答案了。如果你是基於「鴨子辯型」的直觀理解去判斷,那麼我相信你的答案是true。但是我的遺憾地告訴你,在 中,答案是false。這明顯是違揹人類直覺的。於是乎,你會有這麼一個疑問:「字面量型別 1{}型別似乎牛馬不相及,既不形似,也不神似,它怎麼可能是是「字面量空物件」的子型別呢?」

好吧,就像我們在上一節提過的,{}在 typeScript 中,不應該被理解為字面量空物件。它是一個特殊存在。它是一切有值型別的基礎類別。ts 對它這麼定位,似乎也合理。因為呼應了一個事實 - 在 js 中,一切都是物件 (字面量 1 在 js 引擎內部也是會被包成一個物件 - Number()的範例)。

現在,你不妨拿別的各種型別去測試一下它跟 {} 的關係,看看結果是不是跟我說的一樣。最後,有一個注意點值的強調一下。假如我們忽略無處不在,似乎是百變星君的 any{} 的父類別型只有一個 - unknown。不信,我們可以試一試:

type Test = unknown extends {} ? true : false // `Test` 型別的值是 `false`
登入後複製

Test2 型別的值是 false,從而證明了unknown{}的父類別型。

extends 與 any

也許你會覺得,extendsany 有什麼好講得嘛。你上面不是說了「any」既是所有型別的子型別,又是所有型別的父類別型。所以,以下範例程式碼得到的型別一定是true:

type Test = any extends number ? true : false
登入後複製

額......在 中, 結果似乎不是這樣的 - 上面範例程式碼的執行結果是boolean。這到底是怎麼回事呢?這是因為,在 typeScript 的條件型別中,當any 出現在 extends 前面的時候,它是被視為一個聯合裡型別。這個聯合型別有兩個成員,一個是extends 後面的型別,一個非extends 後面的型別。還是用上面的範例舉例子:

type Test = any extends number ? true : false
// 其實等同於
type Test = (number | non-number) extends number ? true : false
// 根據聯合型別的分配率,展開得到
type Test = (number extends number ? true : false) | (non-number extends number ? true : false)
          = true | false
          = boolean

// 不相信我?我們再來試一個例子:
type Test2 = any extends number ? 1 : 2
// 其實等同於
type Test2 = (number | non-number) extends number ? 1 : 2
// 根據聯合型別的分配率,展開得到
type Test = (number extends number ? 1 : 2) | (non-number extends number ? 1 : 2)
          = 1 | 2
登入後複製

也許你會問,如果把 any 放在後面呢?比如:

type Test = number extends any ? true : false
登入後複製

這種情況我們可以依據 「任意型別都是any的子型別」得到最終的結果是true

關於 extends 與 any 的運算結果,總結一下,總共有兩種情況:

  • any extends SomeType(非 any 型別) ? AType : BType 的結果是聯合型別 AType | BType
  • SomeType(可以包含 any 型別) extends any ? AType : BType 的結果是 AType

extends 與 never

在 typeScript 的三元表示式中,當 never 遇見 extends,結果就變得很有意思了。可以換個角度說,是很奇怪。假設,我現在要你實現一個 typeScript utility 去判斷某個型別(不考慮any)是否是never的時候,你可能會不假思索地在想:因為 never 是處在 typeScript 型別層級的最底層,也就是說,除了它自己,沒有任何型別是它的子型別。所以答案肯定是這樣:

type IsNever<T> = T extends never ? true : false
登入後複製

然後,你信心滿滿地給泛型形參傳遞個never去測試,你發現結果是never,而不是true或者false:

type  Test = IsNever<never> // Test 的值為 `never`, 而不是我們期待的  `true`
登入後複製

再然後,你不甘心,你寫下了下面的程式碼去進行再次測試:

type  Test = never extends never ? true : false // Test 的值為 `true`, 符合我們的預期
登入後複製

你會發現,這次的結果卻是符合我們的預期的。此時,你腦海裡面肯定有千萬匹草泥馬奔騰而過。是的,ts 型別系統中,某些行為就是那麼的匪夷所思。

對於這種違背直覺的特性表現,當前的解釋是:當 never 充當實參去範例化泛型形參的時候,它被看作沒有任何成員的聯合型別。當 tsc 對沒有成員的聯合型別執行分配律時,tsc 認為這麼做沒有任何意義,所以就不執行這段程式碼,直接返回 never

那正確的實現方式是什麼啊?是這個:

type IsNever<T> = [T] extends [never] ? true : false
登入後複製

原理是什麼啊?答曰:「通過放入 tuple 中,消除了聯合型別碰上 extends 時所產生的分配律」。

extends 與 聯合型別

上面也提到了,在 typeScript 三元表達中,當 extends 前面的型別是聯合型別的時候,ts 就會產生類似於「乘法分配律」行為表現。具體可以用下面的範例來表述:

type Test = (AType | BType) extends SomeType ? 'yes' : 'no'
          =  (AType extends SomeType ? 'yes' : 'no') | (BType extends SomeType ? 'yes' : 'no')
登入後複製

我們再來看看「乘法分配律」:(a+b)*c = a*c + b*c。對比一下,我們就是知道,三元表示式中的 |就是乘法分配律中的 +, 三元表示式中的 extends 就是乘法分配律中的 *。下面是表達這種類比的虛擬碼:

type Test = (AType + BType) * (SomeType ? 'yes' : 'no')
          =  AType * (SomeType ? 'yes' : 'no') + BType * (SomeType ? 'yes' : 'no')
登入後複製

另外,還有一個很重要的特性是,當聯合型別的泛型形參的出現在三元表示式中的真值或者假值分支語句中,它指代的是正在遍歷的聯合型別的成員元素。在程式設計世界裡面,利用聯合型別的這個特性,我們可以遍歷聯合型別的所有成員型別。比如,ts 內建的 utility Exclude<T,U> 就是利用這種特性所實現的:

type  MyExclude<T,U>= T extends U ? never :  T; // 第二個條件分支語句中, T 指代的是正在遍歷的成員元素
type Test = MyExclude<'a'|'b'|'c', 'a'> // 'b'|'c'
登入後複製

在上面的實現中,在你將型別實參代入到三元表示式中,對於第二個條件分支的T 記得要理解為'a'|'b'|'c'的各個成員元素,而不是理解為完整的聯合型別。

有時候,聯合型別的這種分配律不是我們想要的。那麼,我們該怎麼消除這種特性呢?其實上面在講「extends 與 never 」的時候也提到了。那就是,用方括號[]包住 extends 前後的兩個型別引數。此時,兩個條件分支裡面的聯合型別引數在範例化時候的值將會跟 extends 子句裡面的是一樣的。

// 具有分配律的寫法
type ToArray<Type> = Type extends any ? Type[] : never; //
type StrArrOrNumArr = ToArray<string | number>; // 結果是:`string[] | number[]`

// 消除分配律的寫法
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
type StrArrOrNumArr2 = ToArray<string | number>; // 結果是:`(string | number)[]`
登入後複製

也許你會覺得 string[] | number[](string | number)[]是一樣的,我只能說:「客官,要不您再仔細瞧瞧?」。

extends 判斷型別嚴格相等

在 typeScript 的型別程式設計世界裡面,很多時候我們需要判斷兩個型別是否是一模一樣的,即這裡所說的「嚴格相等」。如果讓你去實現這個 utility 的話,你會怎麼做呢?我相信,不少人會跟我一樣,不假思索地寫下了下面的答案:

type  IsEquals<T,U>= T extends U ? U extends T ? true : false :  false
登入後複製

這個答案似乎是邏輯正確的。因為,如果只有自己才可能既是自己的子型別也是自己的父類別型。然後,我們用很多測試用例去測,似乎結果也都符合我們的預期。直到我們碰到下面的邊緣用例:

type  Test1= IsEquals<never,never> // 期待結果:true,實際結果: never
type  Test2= IsEquals<1,any> // 期待結果:false,實際結果: boolean
type  Test3= IsEquals<{readonly a: 1},{a:1}> // 期待結果:false,實際結果: true
登入後複製

沒辦法, typeScript 的型別系統有太多的違背常識的設計與實現了。如果還是沿用上面的思路,即使你把上面的特定用例修復好了,但是說不定還有其他的邊緣用例躲在某個陰暗的角度等著你。所以,對於「如何判斷兩個 typeScript 型別是嚴格相等」的這個問題上,目前社群裡面從 typeScript 實現原始碼角度上給出了一個終極答案:

type IsEquals<X, Y> =
      (<T>() => (T extends  X ? 1 : 2)) extends
      (<T>() => (T extends  Y ? 1 : 2))
      ? true
      : false;
登入後複製

目前我還沒理解這個終極答案為什麼是行之有效的,但是從測試結果來看,它確實是 work 的,並且被大家所公認。所以,目前為止,對於這個實現只能是死記硬背了。

extends 與型別推導

type Test<A> = A extends SomeShape ? 第一個條件分支 : 第二支條件分支
登入後複製

當 typeScript 的三元表示式遇見型別推導infer SomeType, 在語法上是有硬性要求的:

  • infer 只能出現在 extends 子句中,並且只能出現在 extends 關鍵字後面
  • 緊跟在 infer 後面所宣告的型別形參只能在三元表示式的第一個條件分支(即,真值分支語句)中使用

除了語法上有硬性要求,我們也要正確理解 extends 遇見型別推導的語意。在這個上下文中,infer SomeType 更像是具有某種結構的型別的預留位置。SomeShape 中可以通過 infer 來宣告多個型別形參,它們與一些已知的型別值共同組成了一個代表具有如此形態的SomeShape 。而 A extends SomeShape 是我們開發者在表達:「tsc,請按照顧我所宣告的這種結構去幫我推導得出各個泛型形參在執行時的值,以便供我進一步消費這些值」,而 tsc 會說:「好的,我盡我所能」。

「tsc 會盡我所能地去推匯出具體的型別值」這句話的背後蘊含著不少的 typeScript 未在檔案上交代的行為表現。比如,當型別形參與型別值共同出現在「陣列」,「字串」等可遍歷的型別中,tsc 會產生類似於「子串/子陣列匹配」的行為表現 - 也就是說,tsc 會以非貪婪匹配模式遍歷整個陣列/字串進行子串/陣列匹配,直到匹配到最小的子串/子陣列為止。這個結果,就是我們型別推導的泛型形參在執行時的值。

舉個例子,下面的程式碼是實現一個ReplaceOnce 型別 utility 程式碼:

type ReplaceOnce<
  S extends string,
  From extends string,
  To extends string
> = From extends ""
  ? S
  : S extends `${infer Left}${From}${infer Right}`
  ? `${Left}${To}${Right}`
  : S
  「」
type Test = Replace<"foobarbar", "bar", ""> // 結果是:「foobar」
登入後複製

tsc 在執行上面的這行程式碼「S extends ${infer Left}${From}${infer Right}」的時候,背後做了一個從左到右的「子串匹配」行為,直到匹配到所傳遞進來的子串From為止。這個時候,也是 resolve 出形參LeftRight具體值的時候。

以上範例很好的表達出我想要表達的「當extends 跟型別推導結合到一塊所產生的一些微妙且未見諸於官方檔案的行為表現」。在 typeScript 高階型別程式設計中,善於利用這一點能夠幫助我們去解決很多「子串/子陣列匹配」相關的問題。

總結

在 typeScript 在不同的上下文中,extends 有以下幾個語意:

  • 用於表達型別組合;
  • 用於表達物件導向中「類」的繼承
  • 用於表達泛型的型別約束;
  • 在條件型別(conditional type)中,充當型別表示式,用於求值。

最值得注意的是,extends在條件型別中與其他幾個特殊型別結合所產生的特殊語意。幾個特殊型別是:

  • {}
  • any
  • never
  • 聯合型別

【推薦學習:】

以上就是帶你聊聊typeScript中的extends關鍵字的詳細內容,更多請關注TW511.COM其它相關文章!