TypeScript 中的类型安全

TypeScript 中的类型安全

一月 15, 2019

TypeScript 是目前用来解决 JavaScript 没有静态类型检查问题的最佳选择之一。当程序员试图将毫不相干的类型赋值给一个变量,或是使用一个类型不存在的属性时会给出警告。但是在实际开发过程中仍然能发现很多类型错误导致的异常情况。TypeScript 能在多大程度上保障程序的类型安全呢?

普通类型的相容性

与其它很多静态类型的语言一样,它提供了类型继承和代数数据类型等特性。我们都知道,有类似类型系统的语言一般都有一个规则,父类的引用可以指向子类对象,反之则不行。并且 TypeScript 中使用鸭子类型,即一些对象如果包含某个接口的所有属性,实现了某个接口的所有方法,那么它们就是同一类。

interface Animal {
  age: int;
}

interface Duck {
  age: int;
  quack(): void;
}

const animal: Animal = {
  age: 2,
  quack() {
    console.log('gaga');
  },
};

上面的 animal 对象虽然没有显式实现 Duck 接口,但实现了它的所有属性,所以可以在任何一个需要 Duck 接口的地方使用 animal

function cook(duck: Duck) {
  // ...
}

cook(animal); // OK

因此,可以根据一个对象所包含的属性和方法将它们分为不同的类,并且,如果某一个类型 S 可以填入任何需要类型 T 的地方,就称 S 为 T 的子类型,记作 $ S <: T $。如上面的例子中,Duck 就是 Animal 的子类型。

更复杂一点的类型替换

接下来考虑一个复杂度稍高一点的问题:如果 $ S <: T $,那么对于它们的数组类型,是否有子类型关系 $ S[] <: T[] $ 呢?也就是说,一个需要 Animal[] 的地方,能否使用 Duck[] 安全地替换呢?

function createDucks(): Duck[] {
  // ...
}
const animals: Animal[] = createDucks();
animals.forEach((animal) => {
  console.log(animal.age);
});

这样的操作貌似没有什么问题,因为这个引用会把所有的数组元素当作 Animal 来处理,而 Duck 是可以安全地替代 Animal 的,所以以上的操作也是安全的。但是这里还有另一个例子:

const ducks: Duck[];
const animals: Animals = ducks[];
animals[0] = createAnimal();
ducks[0].quack(); // Error: ducks[0].quack is not a function

这里通过类型为 Animal[] 的引用往一个 Duck[] 的数组中添加了一个类型不为 Duck 的对象,在试图调用 quack 方法的时候,却发现这个对象实际上不是 Duck 类型的。所以在读取操作中,这样的替换是安全的,但是在修改操作中就会有隐患。

如果使用 Animal[] 类型去替换 Duck[] 类型,就会得到刚好相反的结论。此时读取的操作是不安全的(试图引用 Duck 的特有属性却不存在),但是修改却是安全的(往 Animal[] 中添加 Duck 对象不会有问题)。

因此得出结论,即使 $ S <: T $ 成立,$ S[] $ 和 $ T[] $ 之间的子类型关系也是无法确定的。一般地,只有当 $ S = T $ 时,它们才可以安全地相互替换。但在实际操作中,如果不允许这种替换,会给很多程序的编写带来麻烦,因此 TypeScript(以及很多其它语言)允许使用子类型的数组替代父类型的数组。在这里,数组类型的兼容方向与它的元素类型保持一致,被称为「协变」。如果兼容时的替换规则与元素类型相反,则被称为「逆变」。

函数类型的相容性

在 TypeScript 等函数作为一等公民的语言中,函数可以像其它类型变量一样进行赋值,或是作为另一个函数的参数/返回值。那么在什么条件下,一种类型的函数是另一种函数的子类型呢?

先从最简单的函数类型开始,这种函数不接受任何参数,但是有一个返回值。仍然假设有两个类型 $ S $ 和 $ T $,且 $ S <: T $,并分别构造函数类型 $ () \rightarrow S $ 和 $ () \rightarrow T $。因为只有返回值而没有参数,所以只要一个函数的返回值类型能够替代另一个的返回值类型即可。所以 $ () \rightarrow S <: () \rightarrow T $。

对于 $ S \rightarrow void $ 和 $ T \rightarrow void $,情况就没有那么简单。

function f(arg: T) {}
function g(arg: S) {}

const t: T = new T();
f(t); // => g(t)

如果将此处的函数替换,调用 g(t),会带来一些问题。函数 g 期望得到类型 $ S $ 的参数,但是实际提供的是 $ T $,所以函数会在访问子类型 $ S $ 特有的属性时出现错误。再看看反过来的情况,尝试使用函数 f 替代 g

const s: S = new S();
g(s); // => f(s)

这样不会有类型安全问题,f 函数会使用 $ T $ 类型具有的属性,而这些属性 $ S $ 也都包含。所以此时 $ () \rightarrow T <: () \rightarrow S $

综合上面两个例子可以得到结论,函数对于参数类型是逆变的,而对于返回类型是协变的,即 $ T_2 <: T_1, R_1 <: R_2 \Rightarrow (T_1 \rightarrow R_1) <: (T_2 \rightarrow R_2) $。对于多参数的函数,可以把它的参数理解为一个键值为自然数的类型(即数组)。比如 (s: S, t: T) => R 的参数类型就可以看作:

interface Args {
  0: S,
  1: T,
};

这样就可以得出,当 $ n \le m $, 有 $ (S1, S2, …, S_n) \rightarrow R <: (S1, S2, …, S_m) \rightarrow R $。

再来看看 TypeScript 中是怎么处理函数的。如上文所述,参数类型协变或是返回类型逆变时,函数的安全性是不能保证的。但是前面还提到,为了保证程序编写的简洁,TypeScript 认为子类的数组是兼容父类数组的,假设 TypeScript 只允许函数参数逆变,那么考虑 Animal[]Duck[]push 方法:

type AnimalPush = (x: Animal) => number;
type DuckPush = (x: Duck) => number;

那么 $ DuckPush <: AnimalPush $ 是不成立的,这样 Duck[] 也就无法兼容 Animal[]。因此 TypeScript 在这里进行了妥协,允许所有函数的参数是逆变或者协变。

结论

TypeScript 中类型的检查并不严格地遵守类型安全,因此在使用时有一部分类型错误无法通过 TypeScript 的类型检查发现。这种情况下,需要开发者自己采取一些措施保证类型的安全,并且能在出现类型错误时意识到可能有问题的地方。

使用泛型替代更大的类型

父类型数组的引用也可以指向子类型数组,但是如果对它进行修改,就会出现安全隐患。其实在很多情况下可以使用泛型代替父类型实现通用的操作。

function unsafe(animals: Animal[]) {
  // ...
}

function safe<T extends Animal>(animals: T[]) {
  // ...
}

const ducks: Ducks = [foo, bar];

unsafe(ducks);
safe(ducks);

safe 函数中的操作都会基于类型 T 进行,而不像 unsafe 中可能写入一个不符合实际类型的值。

注意函数参数

因为 TypeScript 中函数的参数类型是可以协变的,所以函数接收到的参数类型不一定安全。

type AnimalFunc = (animal: Animal) => void;

const f: AnimalFunc = (duck: Duck) => {
  duck.quack();
};

const animal: Animal = deer;
f(animal); // Error: duck.quack is not a function

对于一些函数,可以使用多个参数来代替一个具有多个属性的参数,如:

function unsafe(params: { foo: number, bar: number }) {
  // ...
}

function safe(foo: number, bar: number) {
  // ...
}

当然前一种函数原型更方便扩展,在具体使用时需要结合实际情况。如果有必要,可以开启 --strictFunctionTypes 选项保证函数的参数类型只能进行逆变。对于 bind, applycall 函数,TypeScript 也在 3.2 版本提供了 --strictBindCallApply 选项开启检查。

谨慎使用 any

对于使用 any 类型的情况,TypeScript 几乎不会做任何的类型检查,但是实际上没有一个值可以胜任所有情况。当使用了 any 时,不会有人提醒程序员去做类型检查,这会给程序留下很大的隐患。TypeScript 3.0 版本以后提供了 unknown 类型,它是子类型关系下的最大元素,因此任何试图直接使用 unknown 类型的行为都会被检查出来,并要求进行类型的检查。在很多情况下,any 应当使用 unknown 来代替。

参考文献

  1. Castagna, G. (1995). Covariance and contravariance: conflict without a cause. ACM Transactions on Programming Languages and Systems (TOPLAS), 17(3), 431-447.
  2. Cardelli, L. (1984). A semantics of multiple inheritance. In Semantics of data types (pp. 51-67). Springer, Berlin, Heidelberg.
  3. FAQ in Microsoft/TypeScript Wiki (2018, June). Retrieved January 18, 2019, from https://github.com/Microsoft/TypeScript/wiki/FAQ.