类型兼容_TypeScript中文文档

2022/3/25

本文译者 Aison,期待你的勘误 (opens new window)原文 (opens new window)

# 类型兼容性

TypeScript里的类型兼容性是基于结构类型系统的, 结构类型系统是一种只使用其成员来表达类型的方式, 它正好与名义(nominal)类型系统形成对比。

interface Pet {
  name: string;
}
class Dog {
  name: string;
}
let pet: Pet;
// OK, because of structural typing
pet = new Dog();

在使用基于名义类型系统的语言,比如C#或Java中,这段代码会报错,因为Person类没有明确说明其实现了Named接口。 TypeScript的结构型类型系统是根据JavaScript代码的典型写法来设计的。 因为JavaScript里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更自然。

# 关于可靠性的注意事项

TypeScript的类型系统允许某些在编译阶段无法确认其安全性的操作。当一个类型系统具此属性时,被当做是“不可靠”的。TypeScript允许这种不可靠行为的发生是经过仔细考虑的。通过这篇文章,我们会解释什么时候会发生这种情况和其有利的一面。

# 开始

TypeScript结构类型系统的基本规则是,如果x要兼容y,那么y至少具有与x相同的成员。 例如,思考以下代码,该代码涉及一个名为Pet的接口,接口拥有一个名为name的属性:

interface Pet {
  name: string;
}
let pet: Pet;
// dog的引用类型是:{ name: string; owner: string; }
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
pet = dog;

为了检查dog类型是否可以赋值给pet类型,编译器将检查pet的每个属性,并在dog中查看是否有对应的兼容属性。在本例中,dog必须拥有一个叫做name的string类型成员属性。dog拥有该属性,所以检查通过,赋值被允许发生。 该赋值规则同样用于检查函数调用参数:

interface Pet {
  name: string;
}
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
function greet(pet: Pet) {
  console.log("Hello, " + pet.name);
}
greet(dog); // OK

注意,dog有个额外的owner属性,但这不会引发错误。 只有目标类型(本例中为Pet)的成员会在兼容性检查中被考虑 这个比较过程是递归进行的,将检查每个成员及子成员的类型。

# 比较两个函数

相对来讲,在比较基本类型和对象类型的时候是比较容易理解的,但如何判断两个函数是兼容的却略微复杂。 下面我们从两个简单的函数入手,它们仅是参数列表略有不同:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
x = y; // Error

要查看x是否能赋值给y,首先看它们的参数列表。 x的每个参数必须能在y里找到对应兼容类型的参数。 注意,参数的名字相同与否并无所谓,我们只关注它们的类型。在第一个赋值中,x的每个参数在y中都能找到对应的参数,所以允许赋值。而第二个赋值错误,则是因为y有个必需的第二个参数,但是x并没有,所以不允许赋值。

你可能会疑惑为什么允许忽略参数,像例子y = x中那样。 原因是忽略额外的参数在JavaScript里是很常见的。 例如,Array#forEach给回调函数传3个参数:数组元素,索引和整个数组。 尽管如此,传入一个只使用第一个参数的回调函数也是很有用的:

let items = [1, 2, 3];
// 并不强制额外的参数
items.forEach((item, index, array) => console.log(item));
// 这样也是可以的!
items.forEach((item) => console.log(item));

下面来看看如何处理返回值类型,创建两个仅是返回值类型不同的函数:

let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });
x = y; // OK
y = x; // Error, 因为x()缺少location属性

类型系统强制源函数的返回值类型必须是目标函数返回值类型的子类型。

# 函数参数双向协变/函数协逆变

当比较函数参数类型时,只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。 这是不安全的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息。 实际上,这极少会发生错误,允许这种模式是为了兼容 JavaScript 中许多常见模式。例如:

type EventType = MyMouseEvent | MyKeyEvent
interface TestEvent {
  timestamp: number
}
interface MyMouseEvent extends TestEvent {
  x: number
  y: number
}
interface MyKeyEvent extends TestEvent {
  keyCode: number
}
function listenEvent (event: EventType, handler: (n: TestEvent) => void): void {
  handler(event)
}
const mouseEvent: MyMouseEvent = {
  timestamp: 123,
  x: 123,
  y: 456
}
const keyEvent: MyKeyEvent = {
  timestamp: 456,
  keyCode: 789
}
// 不安全但好用又常见
listenEvent(mouseEvent, (e: MyMouseEvent) => console.log(e.x + ',' + e.y))
// 不安全的原因:可能有二傻子会传入一个不是MyMouseEvent但是又是Event的类型到listenEvent中,可能这可能导致该方法错误
listenEvent(keyEvent, (e: MyMouseEvent) =>
  console.log(e.x + ',' + e.y)
) // Error!
// 安全但不尽如人意的方案
listenEvent(mouseEvent, (e: TestEvent) =>
  console.log((e as MyMouseEvent).x + ',' + (e as MyMouseEvent).y)
)
listenEvent(mouseEvent, ((e: MyMouseEvent) =>
  console.log(e.x + ',' + e.y)) as (e: TestEvent) => void)
// 仍然禁止(显然错误)。 类型安全将拒绝完全不兼容的类型。
listenEvent(mouseEvent, (e: number) => console.log(e))

你可以使用strictFunctionTypes编译选项,使TypeScript在这种情况下报错。注意,当strict选项为true时,默认开启该选项。

# 关于协逆变/双向协变

# 协变与逆变

案例来自于深入理解JavaScript (opens new window)文章 (opens new window)的翻译 Greyhound (灰狗)是 Dog (狗)的子类型,而 Dog 则是 Animal (动物)的子类型。由于子类型通常是可传递的,因此我们也称 Greyhound 是 Animal 的子类型。 问题:以下哪种类型是 Dog → Dog 的子类型呢? Greyhound → Greyhound Greyhound → Animal Animal → Animal Animal → Greyhound

让我们来思考一下如何解答这个问题。首先我们假设 f 是一个以 Dog → Dog 为参数的函数。它的返回值并不重要,为了具体描述问题,我们假设函数结构体是这样的: f : (Dog → Dog) → String。

现在我想给函数 f 传入某个函数 g 来调用。我们来瞧瞧当 g 为以上四种类型时,会发生什么情况。

  1. 我们假设 g : Greyhound → Greyhound, f(g) 的类型是否安全?

不安全,因为在f内调用它的参数(g)函数时,使用的参数可能是一个不同于灰狗但又是狗的子类型,例如 GermanShepherd (牧羊犬)。

  1. 我们假设 g : Greyhound → Animal, f(g) 的类型是否安全?

不安全。理由同(1)。

  1. 我们假设 g : Animal → Animal, f(g) 的类型是否安全?

不安全。因为 f 有可能在调用完参数之后,让返回值,也就是 Animal (动物)狗叫。并非所有动物都会狗叫。

  1. 我们假设 g : Animal → Greyhound, f(g) 的类型是否安全?

是的,它的类型是安全的。首先,f 可能会以任何狗的品种来作为参数调用,而所有的狗都是动物。其次,它可能会假设结果是一条狗,而所有的灰狗都是狗。

如上所述,我们得出结论: 约定A ≼ B 意味着 A 是 B 的子类型,那么(Animal → Greyhound) ≼ (Dog → Dog)。 返回值类型很容易理解:灰狗是狗的子类型。但参数类型则是相反的:动物是狗的父类!

用合适的术语来描述这个奇怪的表现,可以说我们允许一个函数类型中,返回值类型是协变的,而参数类型是逆变的。返回值类型是协变的,意思是 A ≼ B 就意味着 (T → A) ≼ (T → B) 。参数类型是逆变的,意思是 A ≼ B 就意味着 (B → T) ≼ (A → T) ( A 和 B 的位置颠倒过来了)。

# 双向协变/协逆变

bi:双向;variance:变化;Bivariance:双向变化/双向协变/协逆变。双向协变,其实意思就是说一个参数即是协变的又是逆变的,这源于上述产生的翻译问题,实际上协逆变意味在如上的案例中,你可以将情况1传入其中,这可能导致意外情况的发生,正如在上述案例中可能会产生的错误。

# 可选参数及剩余参数

比较函数兼容性的时候,可选参数与必须参数是可互换的。 源类型上有额外的可选参数不会产生错误,目标类型的可选参数在源类型里没有对应的参数也不会产生错误。 当一个函数有剩余参数时,它被当做无限个可选参数。 这对于类型系统的判断来说是不稳定的,但从运行时的角度来看,可选参数一般来说是不强制的,因为对于大多数函数来说相当于传递了一些undefinded。 有一个好的例子,常见的函数接收一个回调函数并用对于程序员来说是可预知的参数但对类型系统来说是不确定的参数来调用:

function invokeLater(args: any[], callback: (...args: any[]) => void) {
  /* ... 使用参数执行回调函数 ... */
}
// 不可靠 - invokeLater "可能" 提供任意数量的参数
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
// 干扰:x和y是切实需要的,但由于是可选参数,无法发现该错误
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));

# 函数重载

对于有重载的函数,源函数的每个重载都要在目标函数上找到对应的函数签名。 这确保了目标函数可以在所有源函数可调用的地方调用。

# 枚举

枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。例如:

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green;  // Error

#

类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。 比较两个类类型的对象时,只有实例的成员会被比较。 静态成员和构造函数不在比较的范围内。

class Animal {
  feet: number;
  constructor(name: string, numFeet: number) {}
}
class Size {
  feet: number;
  constructor(numFeet: number) {}
}
let a: Animal;
let s: Size;
a = s; // OK
s = a; // OK

# 类的私有成员和受保护成员

类的私有成员和受保护成员会影响兼容性。 当检查类实例的兼容时,如果目标类型包含一个私有成员,那么源类型必须包含来自同一个类的这个私有成员。 同样地,这条规则也适用于包含受保护成员实例的类型检查。 这允许子类赋值给父类,但是不能赋值给其它有同样类型的类。

# 泛型

因为TypeScript是结构性的类型系统,类型参数只影响使用其做为类型一部分的结果类型。例如:

interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK, 因为y和x的结构相匹配

上面代码里,x和y是兼容的,因为它们的结构使用类型参数时并没有什么不同。 把这个例子改变一下,增加一个成员,就能看出是如何工作的了:

interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // Error, 因为x和y不兼容

对于没指定泛型类型的泛型参数时,会把所有泛型参数当成any比较。 然后用结果类型进行比较,就像上面第一个例子。例如:

let identity = function <T>(x: T): T {
  // ...
};
let reverse = function <U>(y: U): U {
  // ...
};
identity = reverse; // OK, 因为 (x: any) => any 匹配 (y: any) => any

目前为止,我们使用了“兼容性”,它在语言规范里没有定义。 在TypeScript里,有两种兼容性:子类型和赋值。 它们的不同点在于,赋值扩展了子类型兼容性,增加了一些规则,允许和any来回赋值,以及enum和对应数字值之间的来回赋值。 语言里的不同地方分别使用了它们之中的机制。 实际上,类型兼容性是由赋值兼容性来控制的,即使在implements和extends语句也不例外。

# Any、unknown、object、void、undefined、null 和 never 的赋值性

# 赋值性表格

# 重申基础

  • 所有类型的值可赋值给其自身类型的变量。
  • any 和 unknown 的值在赋值给其他类型的变量时表现相同;不同在于当它作为变量类型时,不可被赋值any以外的任何类型的变量。
  • unknown 和 never 的表现接近于互相相反。所有类型的变量可被赋值unknown类型的值, never类型的变量可被赋值任意类型的值。任意类型的变量不可被赋值never类型的值, unknown类型的变量不可以被(any类型以外的)任意类型的值赋值。
  • void类型总是不可赋值或被赋值,除以下的例外情况: 1、当void类型作为变量时,仅可被赋值any、unknown类型的值;2、当void类型作为值时,仅可赋值给never、undefined和null类型的变量(当strictNullChecks (opens new window)被关闭, 点击链接查看详情).
  • strictNullChecks (opens new window) 被关闭, null 和 undefined 的表现与 never 相似: 作为变量可被赋值大部分类型的值,作为值不可赋值给大部分类型的变量,他们可以相互赋值。
  • strictNullChecks (opens new window) 被开启, null 和 undefined 的表现类似于 void: 总是不可赋值或被赋值,除以下的例外情况:1、作为变量类型时,仅可被赋值any和unknown类型的值;2、作为值时,仅可赋值给Never类型的值;3、undefined类型的变量总是可被赋值void 类型的值。

# 相关阅读:strictNullChecks

默认:当strict选项开启时默认开启,其他时候默认关闭 相关:strict (opens new window) 当strictNullChecks为假时,null和undefined实际上会被语言所忽视,而这可能导致未期的错误。 当strictNullChecks为真时,null和undefined将拥有自身的显著而确切的类型,此时若你在需要实际值的地方使用他们,将会产生类型错误。

例如下列代码,users.find无法保证它一定能寻找到用户,但你可以在假设它可以找到的情况下编写代码:

declare const loggedInUsername: string;
 
const users = [
  { name: "Oby", age: 12 },
  { name: "Heera", age: 32 },
];
 
const loggedInUser = users.find((u) => u.name === loggedInUsername);
console.log(loggedInUser.age);

设置strictNullChecks为true时,将在你无法保证loggedInUser存在的前提下,产生一个错误以阻止你的尝试使用。

declare const loggedInUsername: string;
 
const users = [
  { name: "Oby", age: 12 },
  { name: "Heera", age: 32 },
];
 
const loggedInUser = users.find((u) => u.name === loggedInUsername);
console.log(loggedInUser.age);
//错误:对象loggedInUser可能为'undefined'.

第二个例子的错误原因——源于Array的find方法——可以如下简化说明:

// 当strictNullChecks为true
type Array = {
  find(predicate: (value: any, index: number) => boolean): S | undefined;
};
// 当strictNullChecks为false,undefined被从类型系统中移除以允许你在假设其
// 总是返回一个结果的情况下编写代码
type Array = {
  find(predicate: (value: any, index: number) => boolean): S;
};
上次更新: 12/20/2024