TypeScript学习(二十):类型兼容| 八月更文挑战

165 阅读7分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战

TS 的 类型兼容是基于 结构化 的子类型。结构化的类型是一种完全基于其类型成员的关系的类型。

以下方为例:

interface Pet{
    name: string;
}

class Dog{
    name: string
}

let pet: Pet;
// OK, because of structual typing
pet = new Dog;

在 C# 或 Java 这种 名义类型 语言上,相同的代码会报错。因为 Dog 类并没有实现 Pet 这个界面。

TS 结构化类型系统的设计是基于 JS 代码的书写。因为 JS 广泛使用匿名对象,如 函数表达式 和 字面量对象。因此,使用一个结构化类型系统代替字面意义上的类型系统,去表达 JS 类库之间的各种关系是一个更为自然的方式。

健全:A Note on Soundness

TS 的类型系统是不健全的。在其编译期间一些无法被类型系统感知的确定的操作是安全的。当一个类型系统有如上特点,那么其就是不健全的。TS 允许不健全的行为是经过完备考虑的,并且通过这个文档,会阐述不健全行为是在哪发生的且其背后的动机。

Starting out

TS 结构化类型系统的基本规则是:如果 y 的类型成员包含 x 的类型成员,那么 x 与 y 的类型是兼容的。

interface Pet{
    name: string;
}
let pet: Pet;
let dog ={ name: "name", owner: "owner"}
// pet: Pet
// dog : { name:string, owner: string}
pet = dog

检查 dog 是否可以被赋值给 pet,编译器会检查 pet 的属性,并且在 dog 的类型中找到其兼容的属性。在这个案例中,dog 必须有一个名为 name 的属性,其类型为 string。事实上 dog 确实满足这个条件,因此两个类型是兼容的。

这个规则也适用于检查函数的参数类型。

interface Pet {
    name: string
}
let dog = { name: "name", owner: "owner"}
function greet(pet:Pet){
    console.log(pet.name)
}

greet(dog)

注意:dog 中有额外的 owner 属性,但是并没有报错。在检查类型兼容的时候,TS 只会考虑目标类型的类型成员。

检查每个类型成员及其子成员的时候,这个检查过程是递归进行的。

比较两个函数

相比于比较原始类型与对象类型的类型兼容性,比较函数之间的类型兼容是更加难。

首先,从下面的简单例子开始,二者仅有参数类型不同

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 仅有一个。因此,赋值不成立。

你可能会疑惑,为什么我们允许第一个赋值式的行为。因为,这种行为在 JS 中是正常现象。以数组函数 forEach 为例,在使用的是经常会忽略 index 参数。

接下来比较一下返回值

let x = () => ({ name: "Alice"})
let y = () => ({ name: "Alice", location: "Seatttle"})

x = y; // OK
y = x; // Error

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

函数参数的双向协变

当比较函数参数的类型时,如果源参数可以可以赋值给目标参数,或者目标函数参数可以赋值给源参数,那么分配就是成功的。这个是类型不健全的,因为调用者会给予函数一个更具体的类型,但是调用这个函数会给予一个不太具体的类型。实际上,这类错误是很少见的。

enum EventType{
    Mouse,
    Keyboard
}
interface Event {
    timestamp: number
}
interface MyMouseEvent extends Event{
    x: number;
    y: number;
}
interfeace MyKeyEvent extends Event {
    keyCode: number;
}    
function listenEvent(eventType: EventType, handler: (n: Event) => void){
    // ...
}

// 类型不健全,但是 常见 且 有用
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + e.y));

// 考虑到健壮性,不期望的用法
listenEvent(EventType.Mouse, (e: Event) => console.log((e as MyMouseEvent).x))
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x) as (e:Event => void))

// 明显的错误,类型不兼容
listenEvent(EventType.Mouse, (e:number) => console.log(e))

可选参数与剩余参数

当比较函数兼容性的时候,可选参数与剩余参数是可以交换的。源类型的额外可选参数并不是一个错误;目标类型的可选参数在对应源类型中并不存在也不是一个错误。

当一个函数有一个剩余参数,那么其会被作为一个有限的可选参数。

这个行为在类型系统中是不健全的。但是从运行时角度来看,可选参数并不是强制的 ,因为会传入 undefined 来代替。

function invokeLater(args:any[], callback:(...args:any[]) => void){
    //
}

// 不健全, 
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 = Staus.Ready
// Error
status = Color.Green

类的兼容性与对象字面量大体上与界面相似。但是有一处不同:类同时具有静态类型与实例类型。当比较两个具有类类型的对象时候,只比较其实例类型的成员。静态成员与构造函数并不影响兼容性。

class Animal{}
class Size{}
let a: Animal;
let s: Size;

a = s; // OK
s = a; // OK

类中的私有成员与保护成员

类中的私有成员与保护成员影响类的兼容性。当检测类实例的兼容性时,如果目标类型有一个私有属性,那么源类型也必须有一个源于相同类型的私有属性。同样地,也适用于保护成员。这是得类与其超类具有兼容性。

class A {  private a: string;  protected b: string;  constructor(a: string, b:string){    this.a = a;    this.b = b  }  }class B {  private a: string;  protected b: string;  constructor(a: string, b:string){    this.a = a;    this.b = b  }  }class C {  public a:string;  constructor(a: string){    this.a = a;  }  }

// error
let a:A = {a:"a", b:"b"}// error
let b:B = {a:"a", b:"b"}

// work
let c:C = {a:"a"}

// error
let a = b 

泛型

当泛型别辅助一个特性值的是,其兼容判定与一个非泛型类型相同。

interface Empty<T>{}

let x: Empty<number>{}
let y: Empty<string>{}
// work
x = y

interface NotEmpty<T>{
    data: T
}

let x: Empty<number>{}
let y: Empty<string>{}
// error
x = y

这里,类型是兼容的,因为没有泛型 T U 的具体值是,会被赋予 any 类型。那么这两个函数是兼容的。

let identity = function <T>(x: T): T {  // ...};let reverse = function <U>(y: U): U {  // ...};identity = reverse; // OK, because (x: any) => any matches (y: any) => any

进阶

子类型与赋值

到目前为止,我们使用了兼容性一词。这并不是一个编程语言维度的标准。在 TS 中,有两种兼容性:子类型 与 赋值。二者区别仅在于 赋值 扩展了子类型的兼容性(any 类型的赋值,枚举类型与数字值的互相赋值)

下图为基础类型的类型兼容性,其中绿色的 √ 为关闭 --strictNullCheck 时成立。

1. 任何类型可以赋值给自身

2. 任何类型都可以分配给 any 与 unknown。但是 unknown 只可以分配给any。

3. never 与 unknown 类型相反。任何值可以分配给unknown,never可以分配给任何值。没有类型可以分配给never,除了any,unknown不可以分配给其他类型

4. 除了 any、unknown、never、undefined、null ( --strictNullChecks 关闭)以外,void 不可以分配给其他类型。

5. --strictNullChecks 关闭,null 和 undefined 与 never 类似:可以分配给大部分类型;大部分类型不可以分配给他们。他们可以互相分配。

6. --strictNullChecks 开启,null 和 undefined 与 void 类似:除 any、unknown、never、void之外,不可以与任意类型分配、被分配。