Skip to main content

Type Compatibility

TS 允许将一些不同类型的变量相互赋值,虽然某种程度上可能会产生一些不可靠的行为,但增加了语言的灵活性,毕竟它的祖宗 JS 就是靠灵活性起家的,而灵活性已经深入前端的骨髓,有时会不得不做一些妥协。

当一个类型 Y 可以被赋值给另一个类型 X 时,我们就可以说类型 X 兼容类型 Y

X (目标类型) = Y (源类型)

Primitive type compatibility

原始类型的兼容性主要关注三点:

null/undefined

strict 模式下 null/undefined 不可以赋值给任意其他类型,但可以被其他任意类型赋值:

let str: string = 'str';
let nl = null;

str = nl; // Error: Type 'null' is not assignable to type 'string'.
nl = str; // OK

如果想避免以上错误,可以关闭以下选项:

tsconfig.json
{
"strictNullChecks": false
}

此时 s 是可以被赋值为 null 的,我们就可以说 nullstring 的子类型:

let str: string = 'str';
let vd: void;

str = null; // OK
vd = null; // OK
vd = undefined; // OK

但是不可以赋值给 never

let nev: never;
nev = null; // Error: Type 'null' is not assignable to type 'never'.

any

any 类型可以赋值给任意其他类型(never 类型除外),也可以被其他任意类型赋值:

let ay: any = 'str';
let num: number;

num = ay; // OK
ay = num; // OK
ay = vd; // OK

never

任何类型都不能赋值给 never:

let nev: never;
let nl = null;

nev = num; // Error
nev = vd; // Error
nev = ay; // Error
nev = nl; // Error

但在非 strict 模式下 never 可以赋值给任意值:

ay = nev; // OK
vd = nev; // OK
num = nev; // OK
nl = nev; // OK

以上是最基本的类型兼容,类型兼容还广泛应用于 enum, interface, class, function, generic 等。

Enum

Enum 的规则比较简单,我们先定义两个 enum:

enum Fruit {
Apple,
Banana
}
enum Color {
Red,
Yellow
}

enum 和 number 是可以完全兼容的:

let fruit: Fruit.Apple = 3; // OK
let no: number = Fruit.Apple; // OK

enum 之间是完全不兼容的:

let color: Color.Red = Fruit.Apple; // Error: Type 'Fruit.Apple' is not assignable to type 'Color.Red'.

Interface

后面的讨论我们会减少使用目标类型,源类型,谁兼容谁,这些术语,因为它们比较绕,可能过一会你就忘记了谁是谁。我们先想想怎么用图去表示:

eSIaVD

不同大小的圆圈代表不同的类型或者成员的多少,先记住以上图的轮廓,我们再来看一个最简单的例子:

interface X {
a: any;
b: any;
}

interface Y {
a: any;
b: any;
c: any;
}

let x: X = {a: 1, b: 2};
let y: Y = {a: 1, b: 2, c: 3};
x = y; // OK
y = x; // Error: Property 'c' is missing in type 'X' but required in type 'Y'.

从以上例子可以看出 y 可以赋值给 x,但 x 不能赋值给 y。其实就是说 y 只要具备 ab,就可以赋值给 x 类型。

TypeScript 类型兼容性是基于 Structural Typing 。它是一种仅根据成员来关联类型的方式,而不是由继承自特定的类或实现特定的接口决定,这与 C# 或 Java 的 Nominal Typing 不太一样。

究其原因,TypeScript 类型系统的设计是也基于 JavaScript 代码通常是如何编写的。由于 JS 广泛使用函数表达式和对象字面量等匿名对象,因此利用 Structural 类型系统而不是 Nominal 类型系统可以更自然地表示 JS 中存在的各种类型间的关系。

tip

其实本质上就是动态语言的类型检查原则:鸭式变形法,总结一下就是:多的可以赋值给少的。

不过还是有点绕,我们可以增强一下最一开始的图:

GqKI1h

Class

Class 和 Interface 相似,只比较结构,本质上也是 Duck Typing。但是要注意的是静态成员和构造函数是不参与比较的,如果两个类具有相同的实例成员,那么他们实例就可以完全兼容。

class A {
constructor(p: number, q: number) {}
id: number = 1;
}

class B {
static s = 1;
constructor(p: number) {}
id: number = 2;
}

let aa = new A(1, 2);
let bb = new B(1);
aa = bb; // OK
bb = aa; // OK

如果含有私有成员,就会导致不能相互兼容:

class A {
constructor(p: number, q: number) {}
id: number = 1;
private name: string = '';
}

class B {
static s = 1;
constructor(p: number) {}
id: number = 2;
private name: string = '';
}

let aa = new A(1, 2);
let bb = new B(1);

aa = bb; // Error: Types have separate declarations of a private property 'name'.
bb = aa; // Error: Types have separate declarations of a private property 'name'.

但即使有私有成员,子类也是可以赋值给父类的:

class A {
constructor(p: number, q: number) {}
id: number = 1;
private name: string = '';
}

class B {
static s = 1;
constructor(p: number) {}
id: number = 2;
private name: string = '';
}

let aa = new A(1, 2);
let bb = new B(1);

class C extends A {
gender: string = ''
}

let cc = new C(1, 2);
aa = cc; // OK
cc = aa; // Error: Property 'gender' is missing in type 'A' but required in type 'C'.

如果子类中含有其它属性或方法,父类是不可以赋值给子类的,因为如果子类实例调用该方法会取不到而报错。

如果子类实际上并没有扩展父类,那么彼此之间其实是可以相互兼容的。

tip

Class 的类型兼容其实也是 Duck Typing,需要注意的是静态成员构造函数是不参与比较的,如果有私有成员,那么只有父类和子类之间是可以兼容的,如果子类扩写了父类,还是遵循“多的可以赋值给少的”原则,即子类实例可以赋值给父类实例。

Duck Typing/Structual Typing 其实也包含父子类继承,“多的可以赋值给少的”的原则同样适用,子类实例可以赋值给父类实例,父类是少的(left),子类是多的(right),如下图所示:

cAt667

Function

Bivariance

要了解函数的类型兼容我们先了解一个很烧脑的概念,看以下例子:

let handler1 = (a: number) => {};
let handler2 = (a: number, b: number, c: number) => {};

handler2 = handler1; // OK
handler1 = handler2; // Error: Type '(a: number, b: number, c: number) => void' is not assignable to type '(a: number) => void'.

是不是感觉哪里不对?和之前介绍的“多的可以赋值给少的”貌似不一样。的确,和之前介绍的确实看起来相悖,这主要是因为 TypeScript 在 strictFunctionTypes 选项启用时对函数的 Input 即函数参数的检查是逆变(contravariance)而非双变(bivariance)。 关于变型 (variance) 对与函数类型意义的相关背景, 请查看协变(covariance)和逆变(contravariance)是什么?

总而言之,因为有不一样的类型,即“变”,才会需要类型兼容,而类型兼容主有两种主要的方式:协变和逆变。

tip

类型 A 赋值给类型 B 的过程,这就是“变”,如果是同一个类型就谈不上变了。而协变就是协助变化,即采用最直接自然的方式,即 Duck Typing,也就是以上说的“多的赋值给少的”。

协变(covariance):多的可以赋值给少的:

GqKI1h

逆变(contravariance):少的可以赋值给多的:

1aSCxc

双变(Bivariance):协变和逆变都支持。

tip

--strictFunctionTypes 默认配置是 false, 即函数(standalone functions not methods)参数采用的是双变,但并不是很安全,strictFunctionTypes 建议设为 true

tsconfig.json
{
"strictFunctionTypes": true
}

此时你肯定会想,为什么 strict 模式下函数 input 采用的是逆变而不是协变?

这主要和 JavaScript 的设计有关,在 JS 中调用函数的时候传较少的参数是非常普遍的,比如 forEach 函数:

let items = [1, 2, 3];

// Don't force these extra parameters
items.forEach((item, index, array) => console.log(item));

// Should be OK!
items.forEach(item => console.log(item));

TS 也遵循了这一点,这样做也很方便,我们就不需要把一个不精确的类型断言成精确类型了。

有了以上知识的铺垫,我们再看函数的类型兼容就不会显得那么“特殊”了。函数的类型兼容性主要看三个方面:

  1. 函数参数的个数
  2. 函数参数的类型
  3. 函数的返回值

之前函数之间直接赋值的例子,可能在实际中并不常用,但函数参数的传递却是经常发生的,其实和函数之间的赋值也是相同的,下面我们先写一个高阶函数 hof 来模拟实际中的一些场景:

type Handler = (a: number, b: number) => void;

function hof(handler: Handler) {
return handler;
}

Numbers of parameters

根据上文介绍的,函数的 input 参数 strict 模式打开后是采用逆变的方式,即“少的可以赋值给多的”原则,再来看以下的例子表现,就很清晰了:

let handler1 = (a: number) => {};
hof(handler1); // OK

let handler2 = (a: number, b: number, c: number) => {};
hof(handler2); // Error: Argument of type '(a: number, b: number, c: number) => void' is not assignable to parameter of type 'Handler'.

以上是固定参数,如果函数中含有可选参数或者剩余参数是否也会遵循该原则呢?

let a = (p1: number, p2: number) => {};
let b = (p1?: number, p2?: number) => {};
let c = (...args: number[]) => {};

我们先想一下,固定参数、剩余参数、可选参数之间按照参数多少排序是什么样的?

固定参数函数参数个数 = 含有剩余参数的函数参数个数 ≥ 含有可选参数的函数参数个数

也即:

a = c ≥ b

有了这个顺序,我们会发现函数之间的赋值依然遵循之前提到的“少的可以赋值给多的”原则:

a = b; // OK
a = c; // OK
b = c; // Error
b = a; // Error
c = a; // OK
c = b; // OK

Types of parameters

如果函数参数的个数满足以上兼容性的要求后,这仅仅是跨过了其中一道门槛,还要继续比较对应的函数类型。先看参数是原始类型时的兼容性:

let handler3 = (aa: string, bb: number) => {};
hof(handler3);
// Argument of type '(aa: string, bb: number) => void' is not assignable to parameter of type 'Handler'.
// Types of parameters 'aa' and 'a' are incompatible.
// Type 'number' is not assignable to type 'string'.
caution

TypeScript(version 2.x/3.x) 对以上代码的错误的第 3 行描述中,个人认为方向是反了,应该是 Type 'string' is not assignable to type 'number'. (TBD)

下面我们看看参数是引用类型时的兼容性,以 interface 为例:

interface Point3D {
x: number;
y: number;
z: number;
}
interface Point2D {
x: number;
y: number;
}

let p3d = (point: Point3D) => {};
let p2d = (point: Point2D) => {};
p3d = p2d; // OK
p2d = p3d; // Error: Types of parameters 'point' and 'point' are incompatible.Property 'z' is missing in type 'Point2D' but required in type 'Point3D'.

成员个数少的可以赋值给成员个数多的,即“少的可以赋值给多的”,依然遵循以上函数 input 参数的逆变机制。

Type of returned value

以上介绍的逆变是用在函数的 input 参数,而对于 output 返回值仍然采用的是协变模式,即:

kRs88T

看一个例子,加深一下:

let f = () => ({name: 'Kimi'});
let g = () => ({name: 'Kimi', age: '18'});
f = g; // OK
g = f; // Error: Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: string; }'.

协变其实也就是 Duck Typing,成员少的可以兼容成员多的。

Overloading

函数的重载也会检查类型兼容性,看下面例子:

// 函数重载的列表
function overload(a: number, b: number): number;
function overload(a: string, b: string): string;

// 函数重载的实现
function overload(a: any, b: any): any {
// ...
}

程序运行的时候编译器会查找重载列表,找到第一个匹配的,来执行下面的函数。所以在重载列表中,函数的参数要多于实现函数的参数,而且返回值类型也要符合相应的要求,以下例子就会报错:

// 函数重载的列表
function overload(a: number): number; // Error: This overload signature is not compatible with its implementation signature.
function overload(a: string, b: string): string;

// 函数重载的实现
function overload(a: any, b: any): any {
// ...
}

增加一个参数或者删掉返回值都会不兼容的。

Generic

Generic interface

interface Empty<T> {}

let obj1: Empty<number> = {}; // OK
let obj2: Empty<string> = {}; // OK
obj1 = obj2; // OK

以上例子我们会发现 TS 并没有报错,这是因为此时泛型接口中没有任何成员。我们再添加一个成员:

interface Empty<T> {
value: T;
}

let obj1: Empty<number> = {}; // Error: Property 'value' is missing in type '{}' but required in type 'Empty<number>'.
let obj2: Empty<string> = {}; // Error: Property 'value' is missing in type '{}' but required in type 'Empty<number>'.
obj1 = obj2; // Error: Type 'Empty<string>' is not assignable to type 'Empty<number>'.

此时 TS 就会报错,也就是只有泛型接口 T 使用的时候才会检查兼容性。

Generic function

如果两个泛型函数定义相同,没有指定类型参数,它们之间也是可以相互兼容的。

let log1 = <T>(x: T): T => {
console.log('x');
return x;
};
let log2 = <U>(y: U): U => {
console.log('y');
return y;
};

log1 = log2;

泛型的类型兼容其实远比以上介绍的要复杂,关于泛型协变和逆变的话题可以先参考以下相关资料,以后再作详细介绍。

Currently, TypeScript treats property values and generic types as covariant, meaning they are fine for reading but not fine for writing.

Reference

  1. TypeScript official docs: Type Compatibility
  2. TypeScript 类型兼容
  3. Stackoverflow: Unsafe implicit conversion of generics in TypeScript
  4. TypeScript 手册翻译系列 10-类型兼容性,作者:一配
  5. [扩展阅读] 鸭子类型(duck typing)
  6. TypeScript in action, By Liang Xiao