TypeScript 型別安全

MangoGoing發表於2021-12-13

前言

TypeScript 中有很多地方涉及到子型別 subtype、父型別 supertype、協變 Covariant 、逆變 Contravariant、雙向協變 Bivariant 和不變 Invariant的概念,如果搞不清這些概念,那麼很可能被報錯搞的無從下手,或者在寫一些複雜型別的時候看到別人可以這麼寫,但是不知道它的緣由。

extends關鍵字

在TypeScript中,extends關鍵字在不同應用場景下有以下三種含義:

  1. 表示繼承/擴充的含義:

繼承父類的方法和屬性

class Animal {
  public weight: number = 0
  public age: number = 0
}

class Dog extends Animal {
  public wang() {
    console.log('汪!')
  }
  public bark() {}
}

class Cat extends Animal {
  public miao() {
    console.log('喵~')
  }
}

繼承型別

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}
 // Dog => { age: number; bark(): void }
  1. 表示約束的含義

在書寫泛型的時候,我們往往需要對型別引數作一定的限制,比如希望傳入的引數都有 name 屬性的陣列我們可以這麼寫:

function getCnames<T extends { name: string }>(entities: T[]):string[] {
  return entities.map(entity => entity.name)
}
  1. 表示分配的含義(可賦值性 assignable
type Animal = {
  name: string;
}
type Dog = {
  name: string;
  bark: () => void
}
type Bool = Dog extends Animal ? 'yes' : 'no';

以下重點介紹表示分配含義,也就是可賦值性的一些用法

字面量型別匹配

type Equal<X, Y> = X extends Y ? true : false;

type Num = Equal<1, 1>; // true
type Str = Equal<'a', 'a'>;
type Boo1 = Equal<true, false>;
type Boo2 = Equal<true, boolean>; // true

容易混淆:型別X可以分配給型別Y,而不是說型別X是型別Y的子集

never

它自然被分配的一些例子:

  • 一個從來不會有返回值的函式(如:如果函式內含有 while(true) {});
  • 一個總是會丟擲錯誤的函式(如:function foo() { throw new Error('Not Implemented') }foo 的返回型別是 never);

never是所有型別的子型別

type A = never extends 'x' ? string : number; 

type P<T> = T extends 'x' ? string : number;
type B = P<never>

複雜型別值匹配

class Animal {
  public weight: number = 0
  public age: number = 0
}

class Dog extends Animal {
  public wang() {
    console.log('wang')
  }
  public bark() {}
}

class Cat extends Animal {
  public miao() {
    console.log('miao')
  }
}

type Equal<X, Y> = X extends Y ? true : false;
type Boo = Equal(Dog, Animal)
type Boo = Equal(Animal, Dog)

type Boo = Equal(Animal, Dog) // false 這是因為 Animal 沒有bark屬性,型別Animal不滿足型別Dog的型別約束。因此,A extends B,是指型別A可以分配給型別B,而不是說型別A是型別B的子集,理解extends在型別三元表示式裡的用法非常重要。

父子型別

還是以動物類做比喻:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}

let animal: Animal
let dog: Dog

在這個例子中,AnimalDog 的父類,DogAnimal的子型別,子型別的屬性比父型別更多,更具體。

  • 在型別系統中,屬性更多的型別是子型別。
  • 在集合論中,屬性更少的集合是子集。

也就是說,子型別是父型別的超集,而父型別是子型別的子集,這是直覺上容易搞混的一點。

記住一個特徵,子型別比父型別更加具體,這點很關鍵。

上述例子中可以看出,animal 是一個「更寬泛」的型別,它的屬性比較少,所以更「具體」的子型別是可以賦值給它的,因為你是知道 animal 上只有 age 這個屬性的,你只會去使用這個屬性,dog 上擁有 animal 所擁有的一切型別,賦值給 animal 是不會出現型別安全問題的。

反之,如果 dog = animal,那麼後續使用者會期望 dog 上擁有 bark 屬性,當他呼叫了 dog.bark() 就會引發執行時的崩潰。

從可賦值性角度來說,子型別是可以賦值給父型別的,也就是 父型別變數 = 子型別變數 是安全的,因為子型別上涵蓋了父型別所擁有的的一切屬性。

當我初學的時候,我會覺得 T extends {} 這樣的語句很奇怪,為什麼可以 extends 一個空型別並且在傳遞任意型別時都成立呢?當搞明白上面的知識點,這個問題也自然迎刃而解了。

到這裡為止,算是基本講完了extends的三種用法,以下進入正題:逆變協變、雙向協變和不變


緣起

ts寫久了,有次在為某個元件寫props型別的時候需要傳一個onClick的時間函式型別時突然有個問題湧現腦海:

為什麼在interface裡面定義函式型別都是寫成函式屬性而不是方法,即:

interface Props {
  handleClick: (arg: string) => number   // 普遍寫法
  handleClick(arg: string): number  // 非主流寫法
}

終於在typescript-eslint中看到規則集時遇到了這個規則

@typescript-eslint/method-signature-style

規則案例如下:

❌ Incorrect

interface T1 {
  func(arg: string): number;
}
type T2 = {
  func(arg: boolean): void;
};
interface T3 {
  func(arg: number): void;
  func(arg: string): void;
  func(arg: boolean): void;
}

✅ Correct

interface T1 {
  func: (arg: string) => number;
}
type T2 = {
  func: (arg: boolean) => void;
};
// this is equivalent to the overload
interface T3 {
  func: ((arg: number) => void) &
    ((arg: string) => void) &
    ((arg: boolean) => void);
}

A method and a function property of the same type behave differently. Methods are always bivariant in their argument, while function properties are contravariant in their argument under strictFunctionTypes.

相同型別的方法和函式屬性的行為不同。方法在它們的引數中總是雙變的,而函式屬性在嚴格功能型別下的引數中是逆變的。

看到這句話後也是一臉懵逼,第一次見到雙向協變和逆變這兩個詞,於是查閱資料找到了他們的概念以及延伸的協變和不變

逆變協變

先來段維基百科的定義

協變與逆變(covariance and contravariance)是在電腦科學中,描述具有父/子型別關係的多個型別通過型別構造器、構造出的多個複雜型別之間是否有父/子型別關係的用語。

咦,父/子型別關係前面好像也提到過,然後說起逆變和協變,又要提到前面說的可分配性,這也就是為什麼文章開頭要花大篇幅去介紹extends關鍵字的原因,在ts中決定型別之間的可分配性是基於結構化型別(structural typing)的

協變(Covariance)

那麼想象一下,現在我們分別有這兩個子型別的陣列,他們之間的父子關係應該是怎麼樣的呢?沒錯,Animal[] 依然是 Dog[] 的父型別,對於這樣的一段程式碼,把子型別賦值給父型別依然是安全的(相容的):

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}
let animals: Animal[]
let dogs: Dog[]

// 你用了一個更加具體的型別去接收原來的Animal型別了,此時你的型別是安全的,animal有的dog肯定有
animals = dogs  // 相容,子(Dog)變父(Animal)(多變少)只要一個型別包含 age,我就可以認為它是一個和 Animal 相容的型別。因此 dog 可以成功賦值給 animal,而對於多出來的 bark() 方法,可以忽略不計。

dog = animal // 不相容

轉變成陣列之後,對於父型別的變數,我們依然只會去找 Dog 型別中一定有的那些屬性(因為子型別更加具體,父型別有的屬性子型別都有)

那麼,對於 type MakeArray<T> = T[] 這個型別構造器來說,它就是 協變(Covariance) 的。

逆變(Contravariance)

逆變確實比較難懂,先做一個有(無)趣(聊)的題(來源:《深入理解TypeScript》)

開始做題之前我們先約定如下的標記:

  • A ≼ B 意味著 AB 的子型別。
  • A → B 指的是以 A 為引數型別,以 B 為返回值型別的函式型別。
  • x : A 意味著 x 的型別為 A

問題:以下哪種型別是 Dog → Dog 的子型別呢?

  1. Greyhound → Greyhound
  2. Greyhound → Animal
  3. Animal → Animal
  4. Animal → Greyhound

讓我們來思考一下如何解答這個問題。首先我們假設 f 是一個以 Dog → Dog 為引數的函式。它的返回值並不重要,為了具體描述問題,我們假設函式結構體是這樣的: f : (Dog → Dog) → String

現在我想給函式 f 傳入某個函式 g 來呼叫。我們來瞧瞧當 g 為以上四種型別時,會發生什麼情況。

1. 我們假設 g : Greyhound → Greyhoundf(g) 的型別是否安全?

不安全,因為在f內呼叫它的引數(g)函式時,使用的引數可能是一個不同於灰狗但又是狗的子型別,例如 GermanShepherd (牧羊犬)。

2. 我們假設 g : Greyhound → Animalf(g) 的型別是否安全?

不安全。理由同(1)。

3. 我們假設 g : Animal → Animalf(g) 的型別是否安全?

不安全。因為 f 有可能在呼叫完引數之後,讓返回值,也就是 Animal (動物)狗叫。並非所有動物都會狗叫。

4. 我們假設 g : Animal → Greyhoundf(g) 的型別是否安全?

是的,它的型別是安全的。首先,f 可能會以任何狗的品種來作為引數呼叫,而所有的狗都是動物。其次,它可能會假設結果是一條狗,而所有的灰狗都是狗。

也就是說:在對型別分別呼叫如下的型別構造器之後:

type MakeFunction<T> = (arg: T) => void

父子型別關係逆轉了(用上面的題來理解:Animal → Greyhound是Dog -> Dog的子型別,但是Animal卻是Dog的父型別),這就是 逆變(Contravariance)

通過 這個例子可以發現:

  • 返回值 -> 協變(Greyhound -> Dog)
  • 入參通常應該為逆變(Animal <- Dog)

雙向協變

TS鴨子型別系統,只要兩個物件結構一致,就認為是同一種型別,而不需要兩者的實際型別有顯式的繼承關係。

函式屬性與函式方法

瞭解了這兩個概念之後我們可以大致猜測雙向協變和不變的定義,雙向協變那就是又可以協變又可以逆變,不變反之,既不能協變也不能逆變,現在我們先到之前困惑的地方:interface Props{}裡面為什麼建議用函式屬性的寫法定義函式型別?

官方的兩個例子再次說明這個問題:

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;
f1 = f2;  // Error with --strictFunctionTypes 
f2 = f1;  // Ok
f2 = f3;  // Error

第一個賦值在預設型別檢查模式下是允許的,但在嚴格函式型別模式下被標記為錯誤。直覺上,預設模式允許賦值,因為它可能是合理的,而嚴格函式型別模式使它成為一個錯誤,因為它不能證明是合理的。在任何一種模式下,第三個賦值都是錯誤的,因為它永遠不會是合理的。

描述示例的另一種方式是,型別在預設型別檢查模式下(x: T) => void變的(即協變逆變)T,但在嚴格函式型別模式下是逆變T

interface Comparer<T> {
    compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok (逆變)

--strictFunctionTypesmode 中,仍然允許第一個賦值,因為它compare被宣告為一個方法。實際上,T是雙變的,Comparer<T>因為它僅用於方法引數位置。但是,更改compare為具有函式型別的屬性會導致更嚴格的檢查生效:

interface Comparer<T> {
    compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Error
dogComparer = animalComparer;  // Ok

結論:在嚴格模式下(或者strictFunctionTypes):型別安全問題將得到保障,與之相反的是預設雙向協變將可能使得你在使用型別的時候變得不安全!

Array

先丟擲一個問題:Array<Dog> 能否為 Array<Animal> 的子型別?(來源:《深入理解TypeScript》)

先看下面這個例子:

interface Animal {
  name: string
}

interface Dog extends Animal {
  wang: () => void
}

interface Cat extends Animal {
  miao: () => void
}

const dogs: Array<Dog> = []
const animals: Animal[] = dogs
// Array入參在ts中是雙向協變的
animals.push(new Cat())

如果列表是不可變的(immutable),那麼答案是肯定的,因為型別很安全。但是假如列表是可變的,那麼答案絕對是否定的!

可變資料

如果翻看typescript的Array的型別,可以看到Array型別定義寫的是函式方法,因此,它的入參是雙向協變的!

interface Array<T> {
    length: number;
    toString(): string;
    toLocaleString(): string;
    pop(): T | undefined;
    push(...items: T[]): number;
    concat(...items: ConcatArray<T>[]): T[];
    concat(...items: (T | ConcatArray<T>)[]): T[];
    join(separator?: string): string;
    reverse(): T[];
    shift(): T | undefined;
    slice(start?: number, end?: number): T[];
    sort(compareFn?: (a: T, b: T) => number): this;
    splice(start: number, deleteCount?: number): T[];
    splice(start: number, deleteCount: number, ...items: T[]): T[];
    unshift(...items: T[]): number;
    indexOf(searchElement: T, fromIndex?: number): number;
    lastIndexOf(searchElement: T, fromIndex?: number): number;
    every(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;
    some(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;
    forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
    filter<S extends T>(callbackfn: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
    filter(callbackfn: (value: T, index: number, array: T[]) => any, thisArg?: any): T[];
    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
    reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
    reduceRight<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    [n: number]: T;
}

可變陣列+雙向協變無法保證型別安全

更安全的陣列型別

interface MutableArray<T> {
        length: number;
    toString: string;
    toLocaleString(): string;
    pop: () => T | undefined;
    push: (...items: T[]) =>  number;
    concat:(...items: ConcatArray<T>[]) => T[];
    join: (separator?: string) => string;
    reverse: () => T[];
    shift:() => T | undefined;
    slice:(start?: number, end?: number) => T[];
    sort:(compareFn?: (a: T, b: T) => number) => this;
    indexOf: (searchElement: T, fromIndex?: number) => number;
      // ...
}

(思考:為什麼)此時我們會發現MutableArray其實是個不可變型別,不再能互相分配

const dogs: MutableArray<Dog> = [] as Dog[];
// error
const animals: MutableArray<Animal> = dogs;

const animals: MutableArray<Animal> = [] as Animal[] ;
// error
const dogs: MutableArray<Dog> = animals

原因是Array型別既存在逆變方法push也存在協變方法pop(滿足相互分配的條件?假設滿足,那麼MutableArray<Dog>和MutableArray<Animal>裡面的pop跟push方法如果做相容?需要同時滿足引數逆變和返回值協變才能相容)

總結

  • 可以使用readonly來標記屬性,使其不可變
  • 更多地使用函式屬性而不是函式方法來定義型別
  • 嘗試讓型別中的協變或者逆變分開,或者讓型別不可變
  • 儘可能避免雙向協變

參考資料

[1]@typescript-eslint/method-signature-style: https://github.com/typescript...

[2]PR: https://github.com/microsoft/...

相關文章