TS 的类型安全怪谈

161 阅读4分钟

引言

TypeScript 里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式, 它正好与名义(nominal)类型形成对比。在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明

来个🌰,以下代码不会报错,虽然 Person 类没有显式实现 Named 接口,但是由于 Person 为实例提供的属性中已经包含了 Named 接口中所声明的属性,在未来访问 name 属性时,一定是安全的

interface Named {
    name: string;
}

class Person {
    name: string;
    age: number;
}

let p: Named;
// OK, because of structural typing
p = new Person();

协变

然后再来看一个父子类型的赋值操作,允许把 dog 赋值给 animal,因为 animal 变量依然为 Animal 类型,dog 变量不仅包含 name 属性,还有 wowo 方法,保证后续 animal 变量的使用是安全的,反之不可以

interface Animal {
    name: string;
}
interface Dog extends Animal {
    wowo: () => void
}
let animal: Animal
let dog: Dog
// 子类型可以给超类型赋值,因为 animal 所需要的属性,dog 都有
animal = dog

逆变

下面是一个函数互相赋值的🌰,这时居然发现,允许超类型给子类型赋值,这是因为函数参数可以被忽略的特点,后续使用 dogFun 时,按照规则,传入的对象包含 name 和 wowo 属性,但是函数体实际是 animalFun,其内部会包含对于 name 属性的处理逻辑,却不会包含对于 wowo 方法的调用,这是安全的,反之不行

interface Animal {
    name: string;
}
interface Dog extends Animal {
    wowo: () => void
}

let animalFun: (animal: Animal) => void = () => { }
let dogFun: (dog: Dog) => void = () => {
    dog.wowo()
}
// 超类型可以给子类型赋值,因为函数可以忽略参数,animalFun 参数少,dogFun 参数多
dogFun = animalFun

原理就是 JS 中允许忽略函数参数使用,实际使用时,可能会发现 animalFun = dogFun 也是被允许的,这时需要开启 strictFunctionTypes 来禁止函数的协变性,因为此报错会产生在运行时,违背了使用 ts 的意义

双向协变

再修改一下🌰,来看看函数的返回值是如何比较的,可以得到一个结论,函数具有协变性和逆变性,也被称为双向协变,一般来说,会禁止函数参数的协变,允许其逆变,保证类型安全

interface Animal {
    name: string;
}
interface Dog extends Animal {
    wowo: () => void
}

let animalFun: () => Animal
let dogFun: () => Dog
// 对于返回值的比较,是协变的
animalFun = dogFun

对于以下🌰,互相赋值无法成功,因为函数参数是逆变的,返回值是协变的,无法兼容,是一种【不变】,下文有提到

interface Animal {
    name: string;
}
interface Dog extends Animal {
    wowo: () => void
}
type FunType<T> = (param: T) => T

let animalFun: FunType<Animal>
let dogFun: FunType<Dog>
// both are not ok
animalFun = dogFun
dogFun = animalFun

不变

有了以上基础,再来看一个同时存在协变和逆变的赋值操作,Invariant 泛型中的 a 属性,遵守协变规则,但是 Contravariant 泛型,却遵守逆变规则,所以相互赋值无法成功

interface SuperType {
  base: string;
}
interface SubType extends SuperType {
  addition: string;
};

type Covariant<T> = T[];
type Contravariant<T> = (p: T) => void;

let coSuperType: Covariant<SuperType> = [];
let coSubType: Covariant<SubType> = [];

let contraSuperType: Contravariant<SuperType> = function (p) { }
let contraSubType: Contravariant<SubType> = function (p) { }

type Invariant<T> = { a: Covariant<T>, b: Contravariant<T> };
let inSuperType: Invariant<SuperType> = { a: coSuperType, b: contraSuperType }
let inSubType: Invariant<SubType> = { a: coSubType, b: contraSubType }

// both are not ok
inSubType = inSuperType;
inSuperType = inSubType;

想要赋值成功,需要改写类型,比如可以把 a 或 b 属性删掉,即可保证赋值成功,也就是只保留协变性或者逆变性

可选参数不影响类型兼容性,换句话说,type Invariant<T> = { a?: Covariant<T>, b?: Contravariant<T> }并不能让类型兼容

有趣的现象

先来看个🌰,现在有父子类型的 Array 的数组,遵守协变规则,子类型可以赋值给超类型,所以 19 行可以赋值成功,但是 21 行却在运行时报错,这是因为 dog 没有 meow 方法,但在编程语言中很难阻止数组的协变,所以在访问数组中元素的属性时,最好配合类型守卫进行访问,防止运行时报错

class Animal { }

class Cat extends Animal {
    meow() {
        console.log('cat meow');
    }
}

class Dog extends Animal {
    wow() {
        console.log('dog wow');
    }
}

let catList: Cat[] = [new Cat()];
let animalList: Animal[] = [new Animal()];
let dog = new Dog();

animalList = catList;
animalList.push(dog);
catList.forEach(cat => cat.meow()); // cat.meow is not a function