TypeScript 类型安全

前言

TypeScript 中有很多地方涉及到子类型 subtype、父类型 supertype、协变 Covariant 、逆变 Contravariant、双向协变 Bivariant 和不变 Invariant的概念,如果搞不清这些概念,那么很可能被报错搞的无从下手,或者在写一些复杂类型的时候看到别人可以这么写,但是不知道它的缘由。

extends关键字

在TypeScript中,extends关键字在不同应用场景下有以下三种含义:

  1. 表示继承/拓展的含义:

继承父类的方法和属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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('喵~')
}
}

继承类型

1
2
3
4
5
6
7
8
interface Animal {
age: number
}

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

在书写泛型的时候,我们往往需要对类型参数作一定的限制,比如希望传入的参数都有 name 属性的数组我们可以这么写:

1
2
3
function getCnames<T extends { name: string }>(entities: T[]):string[] {
return entities.map(entity => entity.name)
}
  1. 表示分配的含义(可赋值性 assignable
1
2
3
4
5
6
7
8
type Animal = {
name: string;
}
type Dog = {
name: string;
bark: () => void
}
type Bool = Dog extends Animal ? 'yes' : 'no';

以下重点介绍表示分配含义,也就是可赋值性的一些用法

字面量类型匹配

1
2
3
4
5
6
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是所有类型的子类型

1
2
3
4
type A = never extends 'x' ? string : number; 

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

复杂类型值匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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在类型三元表达式里的用法非常重要。

父子类型

还是以动物类做比喻:

1
2
3
4
5
6
7
8
9
10
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里面定义函数类型都是写成函数属性而不是方法,即:

1
2
3
4
interface Props {
handleClick: (arg: string) => number // 普遍写法
handleClick(arg: string): number // 非主流写法
}

终于在typescript-eslint中看到规则集时遇到了这个规则

@typescript-eslint/method-signature-style

规则案例如下:

❌ Incorrect

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
5
6
7
8
9
10
11
12
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[] 的父类型,对于这样的一段代码,把子类型赋值给父类型依然是安全的(兼容的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 可能会以任何狗的品种来作为参数调用,而所有的狗都是动物。其次,它可能会假设结果是一条狗,而所有的灰狗都是狗。

也就是说:在对类型分别调用如下的类型构造器之后:

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

父子类型关系逆转了(用上面的题来理解:Animal → Greyhound是Dog -> Dog的子类型,但是Animal却是Dog的父类型),这就是 **逆变(Contravariance)**。

通过 这个例子可以发现:

  • 返回值 -> 协变(Greyhound -> Dog)
  • 入参通常应该为逆变(Animal <- Dog)

双向协变

TS鸭子类型系统,只要两个对象结构一致,就认为是同一种类型,而不需要两者的实际类型有显式的继承关系。

函数属性与函数方法

了解了这两个概念之后我们可以大致猜测双向协变和不变的定义,双向协变那就是又可以协变又可以逆变,不变反之,既不能协变也不能逆变,现在我们先到之前困惑的地方:interface Props{}里面为什么建议用函数属性的写法定义函数类型?

官方的两个例子再次说明这个问题:

1
2
3
4
5
6
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

1
2
3
4
5
6
7
8
9
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为具有函数类型的属性会导致更严格的检查生效:

1
2
3
4
5
6
7
8
9
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》)

先看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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类型定义写的是函数方法,因此,它的入参是双向协变的!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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;
}

可变数组+双向协变无法保证类型安全

更安全的数组类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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其实是个不可变类型,不再能互相分配

1
2
3
4
5
6
7
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和MutableArray里面的pop跟push方法如果做兼容?需要同时满足参数逆变和返回值协变才能兼容)

总结

  • 可以使用readonly来标记属性,使其不可变
  • 更多地使用函数属性而不是函数方法来定义类型
  • 尝试让类型中的协变或者逆变分开,或者让类型不可变
  • 尽可能避免双向协变

参考资料

[1]@typescript-eslint/method-signature-style: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/method-signature-style.md

[2]PR: https://github.com/microsoft/TypeScript/pull/18654