基础类型
13、接口
类似于对象类型字面量,接口类型也能够表示任意的对象类型。不同的是,接口类型能够给对象类型命名以及定义类型参数。接口类型无法表示原始类型,如boolean类型等。
接口声明只存在于编译阶段,在编译后生成的JavaScript代码中不包含任何接口代码。
13.1、接口声明
通过接口声明能够定义一个接口类型。接口声明的基础语法如下所示:
ts
interface InterfaceName
{
TypeMember;
TypeMember;
...
}
在该语法中,interface是关键字,InterfaceName表示接口名,它必须是合法的标识符,TypeMember表示接口的类型成员,所有类型成员都置于一对大括号"{}"之内。
按照惯例,接口名的首字母需要大写。因为接口定义了一种类型,而类型名的首字母通常需要大写。示例如下:
ts
interface Shape { }
在接口名之后,由一对大括号"{}"包围起来的是接口类型中的类型成员。这部分的语法与5.11.3节中介绍的对象类型字面量的语法完全相同。从语法的角度来看,接口声明就是在对象类型字面量之前添加了interface关键字和接口名。因此,在5.11.3节中介绍的语法规则同样适用于接口声明。例如,类型成员间的分隔符和类型成员的尾后分号、逗号。
同样地,接口类型的类型成员也分为以下五类:
- 属性签名
- 调用签名
- 构造签名
- 方法签名
- 索引签名
13.2、属性签名
属性签名声明了对象类型中属性成员的名称和类型。属性签名的语法如下所示:
ts
PropertyName: Type;
在该语法中,PropertyName表示对象属性名,可以为标识符、字符串、数字和可计算属性名;Type表示该属性的类型。示例如下:
ts
interface Point {
x: number;
y: number;
}
13.3、调用签名
调用签名定义了该对象类型表示的函数在调用时的类型参数、参数列表以及返回值类型。调用签名的语法如下所示:
ts
(ParameterList): Type
在该语法中,ParameterList表示函数形式参数列表类型;Type表示函数返回值类型,两者都是可选的。示例如下:
ts
interface ErrorConstructor {
(message?: string): Error;
}
13.4、构造签名
构造签名定义了该对象类型表示的构造函数在使用new运算符调用时的参数列表以及返回值类型。构造签名的语法如下所示:
ts
new (ParameterList): Type
在该语法中,new是运算符关键字;ParameterList表示构造函数形式参数列表类型;Type表示构造函数返回值类型,两者都是可选的。示例如下:
ts
interface ErrorConstructor {
new (message?: string): Error;
}
13.5、方法签名
方法签名是声明函数类型的属性成员的简写。方法签名的语法如下所示:
ts
PropertyName(ParameterList): Type
在该语法中,PropertyName表示对象属性名,可以为标识符、字符串、数字和可计算属性名;ParameterList表示可选的方法形式参数列表类型;Type表示可选的方法返回值类型。从语法的角度来看,方法签名是在调用签名之前添加一个属性名作为方法名。
下例中定义了Document接口,它包含一个方法签名类型成员。该方法的方法名为getElementById,它接受一个string类型的参数并返回"HTMLElement | null"类型的值。示例如下:
ts
interface Document {
getElementById(elementId: string): HTMLElement | null;
}
之所以说方法签名是声明函数类型的属性成员的简写,是因为方法签名可以改写为具有同等效果但语法稍显复杂的属性签名。我们知道方法签名的语法如下所示:
ts
PropertyName(ParameterList): Type
将上述方法签名改写为具有同等效果的属性签名,如下所示:
ts
PropertyName: { (ParameterList): Type }
在改写后的语法中,属性名保持不变并使用对象类型字面量和调用签名来表示函数类型。由于该对象类型字面量中仅包含一个调用签名,因此也可以使用函数类型字面量来代替对象类型字面量。示例如下:
ts
PropertyName: (ParameterList) => Type
下面我们通过一个真实的例子来演示这三种可以互换的接口定义方式:
ts
interface A {
f(x: boolean): string; // 方法签名
}
interface B {
f: { (x: boolean): string }; // 属性签名和对象类型字面量
}
interface C {
f: (x: boolean) => string; // 属性签名和函数类型字面量
}
此例中我们定义了三个接口A、B和C,它们都表示同一种类型,即定义了方法f的对象类型,方法f接受一个boolean类型的参数并返回string类型的值。
13.6、索引签名
JavaScript支持使用索引去访问对象的属性,即通过方括号"[]"语法去访问对象属性。一个典型的例子是数组对象,我们既可以使用数字索引去访问数组元素,也可以使用字符串索引去访问数组对象上的属性和方法。示例如下:
ts
const colors = ['red', 'green', 'blue'];
// 访问数组中的第一个元素
const red = colors[0];
// 访问数组对象的length属性
const len = colors['length'];
接口中的索引签名能够描述使用索引访问的对象属性的类型。索引签名只有以下两种:
- 字符串索引签名。
- 数值索引签名。
13.6.1、字符串索引签名
字符串索引签名的语法如下所示:
ts
[IndexName: string]: Type
在该语法中,IndexName表示索引名,它可以为任意合法的标识符。索引名只起到占位的作用,它不代表真实的对象属性名;在字符串索引签名中,索引名的类型必须为string类型;Type表示索引值的类型,它可以为任意类型。示例如下:
ts
interface A {
[prop: string]: number;
}
一个接口中最多只能定义一个字符串索引签名。字符串索引签名会约束该对象类型中所有属性的类型。例如,下例中的字符串索引签名定义了索引值的类型为number类型。那么,该接口中所有属性的类型必须能够赋值给number类型。示例如下:
ts
interface A {
[prop: string]: number;
a: number;
b: 0;
c: 1 | 2;
}
此例中,属性a、b和c的类型都能够赋值给字符串索引签名中定义的number类型,因此不会产生错误。接下来,我们再来看一个错误的例子:
ts
interface B {
[prop: string]: number;
a: boolean; // 编译错误
b: () => number; // 编译错误
c(): number; // 编译错误
}
此例中,字符串索引签名中定义的索引值类型依旧为number类型。属性a的类型为boolean类型,它不能赋值给number类型,因此产生编译错误。属性b和方法c的类型均为函数类型,不能赋值给number类型,因此也会产生编译错误。
13.6.2、数值索引签名
数值索引签名的语法如下所示:
ts
[IndexName: number]: Type
在该语法中,IndexName表示索引名,它可以为任意合法的标识符。索引名只起到占位的作用,它不代表真实的对象属性名;在数值索引签名中,索引名的类型必须为number类型;Type表示索引值的类型,它可以为任意类型。示例如下:
ts
interface A {
[prop: number]: string;
}
一个接口中最多只能定义一个数值索引签名。数值索引签名约束了数值属性名对应的属性值的类型。示例如下:
ts
interface A {
[prop: number]: string;
}
const obj: A = ['a', 'b', 'c'];
obj[0]; // string
若接口中同时存在字符串索引签名和数值索引签名,那么数值索引签名的类型必须能够赋值给字符串索引签名的类型。因为在JavaScript中,对象的属性名只能为字符串(或Symbol)。虽然JavaScript也允许使用数字等其他值作为对象的索引,但最终它们都会被转换为字符串类型。因此,数值索引签名能够表示的属性集合是字符串索引签名能够表示的属性集合的子集。
下例中,字符串索引签名的类型为number类型,数值索引签名的类型为数字字面量联合类型"0 | 1"。由于"0 | 1"类型能够赋值给number类型,因此该接口定义是正确的。示例如下:
ts
interface A {
[prop: string]: number;
[prop: number]: 0 | 1;
}
但如果我们交换字符串索引签名和数值索引签名的类型,则会产生编译错误。示例如下:
ts
interface A {
[prop: string]: 0 | 1;
[prop: number]: number; // 编译错误
}
13.7、可选属性与方法
在默认情况下,接口中属性签名和方法签名定义的对象属性都是必选的。在给接口类型赋值时,如果未指定必选属性则会产生编译错误。示例如下:
ts
interface Foo {
x: string;
y(): number;
}
const a: Foo = { x: 'hi' };
// ~
// 编译错误!缺少属性 'y'
const b: Foo = { y() { return 0; } };
// ~
// 编译错误!缺少属性 'x'
// 正确
const c: Foo = {
x: 'hi',
y() { return 0; }
};
我们可以在属性名或方法名后添加一个问号"?",从而将该属性或方法定义为可选的。可选属性签名和可选方法签名的语法如下所示:
ts
PropertyName?: Type
PropertyName?(ParameterList): Type
下例中,接口Foo的属性x和方法y都是可选的:
ts
interface Foo {
x?: string;
y?(): number;
}
const a: Foo = {}
const b: Foo = { x: 'hi' }
const c: Foo = { y() { return 0; } }
const d: Foo = { x: 'hi', y() { return 0; } }
如果接口中定义了重载方法,那么所有重载方法签名必须同时为必选的或者可选的。示例如下:
ts
// 正确
interface Foo {
a(): void;
a(x: boolean): boolean;
b?(): void;
b?(x: boolean): boolean;
}
interface Bar {
a(): void;
a?(x: boolean): boolean;
// ~
// 编译错误:重载签名必须全部为必选的或可选的
}
13.8、只读属性与方法
在接口声明中,使用readonly修饰符能够定义只读属性。readonly修饰符只允许在属性签名和索引签名中使用,具体语法如下所示:
ts
readonly PropertyName: Type;
readonly [IndexName: string]: Type
readonly [IndexName: number]: Type
例如,下例的接口A中定义了只读属性a和只读的索引签名:
ts
interface A {
readonly a: string;
readonly [prop: string]: string;
readonly [prop: number]: string;
}
若接口中定义了只读的索引签名,那么接口类型中的所有属性都是只读属性。示例如下:
ts
interface A {
readonly [prop: string]: number;
}
const a: A = { x: 0 };
a.x = 1; // 编译错误!不允许修改属性值
如果接口中既定义了只读索引签名,又定义了非只读的属性签名,那么非只读的属性签名定义的属性依旧是非只读的,除此之外的所有属性都是只读的。例如,下例的接口A中定义了只读索引签名和非只读属性x。最终的结果为,属性x是非只读的,其余的属性为只读属性。示例如下:
ts
interface A {
readonly [prop: string]: number;
x: number;
}
const a: A = { x: 0, y: 0 };
a.x = 1; // 正确
a.y = 1; // 错误
13.9、接口的继承
接口可以继承其他的对象类型,这相当于将继承的对象类型中的类型成员复制到当前接口中。接口可以继承的对象类型如下:
- 接口。
- 对象类型的类型别名。
- 类。
- 对象类型的交叉类型。
接口的继承需要使用extends关键字。下例中,Circle接口继承了Shape接口。我们可以将Circle接口称作子接口,同时将Shape接口称作父接口。示例如下:
ts
interface Shape {
name: string;
}
interface Circle extends Shape {
radius: number;
}
一个接口可以同时继承多个接口,父接口名之间使用逗号分隔。下例中 ,Circle接口同时继承了Style接口和Shape接口:
ts
interface Style {
color: string;
}
interface Shape {
name: string;
}
interface Circle extends Style, Shape {
radius: number;
}
当一个接口继承了其他接口后,子接口既包含了自身定义的类型成员,也包含了父接口中的类型成员。下例中,Circle接口同时继承了Style接口和Shape接口,因此Circle接口中包含了color、name和radius属性:
ts
interface Style {
color: string;
}
interface Shape {
name: string;
}
interface Circle extends Style, Shape {
radius: number;
}
const c: Circle = {
color: 'red',
name: 'circle',
radius: 1
};
如果子接口与父接口之间存在同名的类型成员,那么子接口中的类型成员具有更高的优先级。同时,子接口与父接口中的同名类型成员必须是类型兼容的。也就是说,子接口中同名类型成员的类型需要能够赋值给父接口中同名类型成员的类型,否则将产生编译错误。示例如下:
ts
interface Style {
color: string;
}
interface Shape {
name: string;
}
interface Circle extends Style, Shape {
name: 'circle';
color: number;
// ~~~~~~~~~~~~~
// 编译错误:'color' 类型不兼容,
// 'number' 类型不能赋值给 'string' 类型
}
此例中,Circle接口同时继承了Style接口和Shape接口。Circle接口与父接口之间存在同名的属性name和color。Circle接口中name属性的类型为字符串字面量类型'circle',它能够赋值给Shape接口中string类型的name属性,因此是正确的。而Circle接口中color属性的类型为number,它不能够赋值给Style接口中string类型的color属性,因此产生编译错误。
如果仅是多个父接口之间存在同名的类型成员,而子接口本身没有该同名类型成员,那么父接口中同名类型成员的类型必须是完全相同的,否则将产生编译错误。示例如下:
ts
interface Style {
draw(): { color: string };
}
interface Shape {
draw(): { x: number; y: number };
}
interface Circle extends Style, Shape {}
// ~~~~~~
// 编译错误
此例中,Circle接口同时继承了Style接口和Shape接口。Style接口和Shape接口都包含一个名为draw的方法,但两者的返回值类型不同。当Circle接口尝试将两个draw方法合并时发生冲突,因此产生了编译错误。
解决这个问题的一个办法是,在Circle接口中定义一个同名的draw方法。这样Circle接口中的draw方法会拥有更高的优先级,从而取代父接口中的draw方法。这时编译器将不再进行类型合并操作,因此也就不会发生合并冲突。但是要注意,Circle接口中定义的draw方法一定要与所有父接口中的draw方法是类型兼容的。示例如下:
ts
interface Style {
draw(): { color: string };
}
interface Shape {
draw(): { x: number; y: number };
}
interface Circle extends Style, Shape {
draw(): { color: string; x: number; y: number };
}
此例中,Circle接口中定义了一个draw方法,它的返回值类型为"{color: string; x: number; y: number }"。它既能赋值给"{ color:string }"类型,也能赋值给"{ x: number; y: number }"类型,因此不会产生编译错误。
14、类型别名
如同接口声明能够为对象类型命名,类型别名声明则能够为TypeScript中的任意类型命名。
14.1、类型别名声明
类型别名声明能够定义一个类型别名,它的基本语法如下所示:
ts
type AliasName = Type
在该语法中,type是声明类型别名的关键字;AliasName表示类型别名的名称;Type表示类型别名关联的具体类型。
类型别名的名称必须为合法的标识符。由于类型别名表示一种类型,因此类型别名的首字母通常需要大写。同时需要注意,不能使用TypeScript内置的类型名作为类型别名的名称,例如boolean、number和any等。下例中,我们声明了一个类型别名Point,它表示包含两个属性的对象类型:
ts
type Point = { x: number; y: number };
类型别名引用的类型可以为任意类型,例如原始类型、对象类型、联合类型和交叉类型等。示例如下:
ts
type StringType = string;
type BooleanType = true | false;
type Point = { x: number; y: number; z?: number };
在类型别名中,也可以引用其他类型别名。示例如下:
ts
type Numeric = number | bigint;
// string | number | bigint
type StringOrNumber = string | Numeric;
类型别名不会创建出一种新的类型,它只是给已有类型命名并直接引用该类型。在程序中,使用类型别名与直接使用该类型别名引用的类型是完全等价的。因此,在程序中可以直接使用类型别名引用的类型来替换掉类型别名。示例如下:
ts
type Point = { x: number; y: number };
let a: Point;
// let a: { x: number; y: number };
在程序中,可能会有一些比较复杂的或者书写起来比较长的类型,这时我们就可以声明一个类型别名来引用该类型,这也便于我们对这个类型进行重用。例如,下例中的DecimalDigit类型比较长,如果在每个引用该类型的地方都完整地写出该类型会很不方便。使用类型别名不但能够简化代码,还能够给该类型起一个具有描述性的名字。示例如下:
ts
type DecimalDigit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
const digit: DecimalDigit = 6;
14.2、递归的类型别名
一般情况下,在类型别名声明中赋值运算符右侧的类型不允许引用当前定义的类型别名。因为类型别名对其引用的类型使用的是及早求值的策略,而不是惰性求值的策略。因此,如果类型别名引用了自身,那么在解析类型别名时就会出现无限递归引用的问题。示例如下:
ts
type T = T;
// ~
// 编译错误!类型别名 'T' 存在循环的自身引用
在TypeScript 3.7版本中,编译器对类型别名的解析进行了一些优化。在类型别名所引用的类型中,使用惰性求值的策略来解析泛型类型参数。因此,允许在泛型类型参数中递归地使用类型别名。总结起来,目前允许在以下场景中使用递归的类型别名:
- 若类型别名引用的类型为接口类型、对象类型字面量、函数类型字面量和构造函数类型字面量,则允许递归引用类型别名。示例如下:
ts
type T0 = { name: T0 };
type T1 = () => T1;
type T2 = new () => T2;
若类型别名引用的是数组类型或元组类型,则允许在元素类型中递归地引用类型别名。示例如下:
ts
type T0 = Array<T0>;
type T1 = T1[];
type T3 = [number, T3];
若类型别名引用的是泛型类或泛型接口,则允许在类型参数中递归的引用类型别名。关于泛型的详细介绍请参考6.1节。示例如下:
ts
interface A<T> {
name: T;
}
type T0 = A<T0>;
class B<T> {
name: T | undefined;
}
type T1 = B<T1>;
通过递归的类型别名能够定义一些特别常用的类型。TypeScript官方文档中给出了使用递归的类型别名来定义Json类型的例子,示例如下:
ts
type Json =
| string
| number
| boolean
| null
| { [property: string]: Json }
| Json[];
const data: Json = {
name: 'TypeScript',
version: { major: 3 }
};
14.3、类型别名与接口
类型别名与接口相似,它们都可以给类型命名并通过该名字来引用表示的类型。虽然在大部分场景中两者是可以互换使用的,但类型别名和接口之间还是存在一些差别。
区别之一,类型别名能够表示非对象类型,而接口则只能表示对象类型。因此,当我们想要表示原始类型、联合类型和交叉类型等类型时只能使用类型别名。示例如下:
ts
type NumericType = number | bigint;
区别之二,接口可以继承其他的接口、类等对象类型,而类型别名则不支持继承。示例如下:
ts
interface Shape {
name: string;
}
interface Circle extends Shape {
radius: number;
}
若要对类型别名实现类似继承的功能,则需要使用一些变通方法。例如,当类型别名表示对象类型时,可以借助于交叉类型来实现继承的效果。示例如下:
ts
type Shape = { name: string };
type Circle = Shape & { radius: number };
function foo(circle: Circle) {
const name = circle.name;
const radius = circle.radius;
}
此例中的方法只适用于表示对象类型的类型别名。如果类型别名表示非对象类型,则无法使用该方法。
区别之三,接口名总是会显示在编译器的诊断信息(例如,错误提示和警告)和代码编辑器的智能提示信息中,而类型别名的名字只在特定情况下才会显示出来。示例如下:
ts
type NumericType = number | bigint;
interface Circle {
radius: number;
}
function f(value: NumericType, circle: Circle) {
const bar: boolean = value;
// ~~~
// 编译错误!错误消息如下:
// Type 'number | bigint' is not assignable to
// type 'boolean'.
const baz: boolean = circle;
// ~~~
// 编译错误!错误消息如下:
// Type 'Circle' is not assignable to type 'boolean'.
}
此例中,分别定义了NumericType类型别名和Circle接口。在f函数中,我们有意制造了两个和它们有关的类型错误。第11行,与类型别名有关的错误消息没有显示出类型别名的名字,而是将类型别名表示的具体类型展开显示,即"number | bigint"联合类型。第17行,在与接口有关的错误消息中直接显示了接口的名字'Circle'。
只有当类型别名表示数组类型、元组类型以及类或接口的泛型实例类型时,才会在相关提示信息中显示类型别名的名字。示例如下:
ts
type Point = [number, number];
function f(value: Point) {
const bar: boolean = value;
// ~~~
// 编译错误!错误消息如下:
// Type 'Point' is not assignable to type 'boolean'.
}
区别之四,接口具有声明合并的行为,而类型别名则不会进行声明合并。示例如下:
ts
interface A {
x: number;
}
interface A {
y: number;
}
此例中,定义了两个同名接口A,最终这两个接口中的类型成员会被合并。合并后的接口A如下所示:
ts
interface A {
x: number;
y: number;
}
15、类
JavaScript是一门面向对象的编程语言,它允许通过对象来建模和解决实际问题。同时,JavaScript也支持基于原型链的对象继承机制。虽然大多数的面向对象编程语言都支持类,但是JavaScript语言在很长一段时间内都没有支持它。在JavaScript程序中,需要使用函数来实现类的功能。
在ECMAScript 2015规范中正式地定义了类。同时,TypeScript语言也对类进行了全面的支持。
15.1、类的定义
虽然JavaScript语言支持了类,但其本质上仍是函数,类是一种语法糖。TypeScript语言对JavaScript中的类进行了扩展,为其添加了类型支持,如实现接口、泛型类等。
定义一个类需要使用class关键字。类似于函数定义,类的定义也有以下两种方式:
- 类声明
- 类表达式
15.1.1、类声明
类声明能够创建一个类,类声明的语法如下所示:
ts
class ClassName {
// ...
}
在该语法中,class是关键字;ClassName表示类的名字。在类声明中的类名是必选的。按照惯例,类名的首字母应该大写。示例如下:
ts
class Circle {
radius: number;
}
const c = new Circle();
此例中,我们声明了一个Circle类,它包含一个number类型的radius属性。使用new关键字能够创建类的实例。
与函数声明不同的是,类声明不会被提升,因此必须先声明后使用。示例如下:
ts
const c0 = new Circle(); // 错误
class Circle {
radius: number;
}
const c1 = new Circle(); // 正确
15.1.2、类表达式
类表达式是另一种定义类的方式,它的语法如下所示:
ts
const Name = class ClassName {
// ...
};
在该语法中,class是关键字;Name表示引用了该类的变量名;ClassName表示类的名字。在类表达式中,类名ClassName是可选的。
例如,下例中使用类表达式定义了一个匿名类,同时使用常量Circle引用了该匿名类:
ts
const Circle = class {
radius: number;
};
const a = new Circle();
如果在类表达式中定义了类名,则该类名只能够在类内部使用,在类外不允许引用该类名。示例如下:
ts
const A = class B {
name = B.name;
};
const b = new B(); // 错误
15.2、成员变量
在类中定义成员变量的方法如下所示:
ts
class Circle {
radius: number = 1;
}
此例中,Circle类只包含一个成员变量。其中,radius是成员变量名,成员变量名之后的类型注解定义了该成员变量的类型。最后,我们将该成员变量的初始值设置为1。除了在成员变量声明中设置初始值,我们还可以在类的构造函数中设置成员变量的初始值。示例如下:
ts
class Circle {
radius: number;
constructor() {
this.radius = 1;
}
}
此例中,在构造函数里将radius成员变量的值初始化为1。同时注意,在构造函数中引用成员变量时需要使用this关键字。
15.2.1、--strictPropertyInitialization
虽然为类的成员变量设置初始值是可选的,但是对成员变量进行初始化是一个好的编程实践,它能够有效避免使用未初始化的值而引发的错误。因此,TypeScript提供了"--strictPropertyInitialization"编译选项来帮助严格检查未经初始化的成员变量。当启用了该编译选项时,成员变量必须在声明时进行初始化或者在构造函数中进行初始化,否则将产生编译错误。
需要注意的是,"--strictPropertyInitialization"编译选项必须与"--strictNullChecks"编译选项同时启用,否则"--strictPropertyInitialization"编译选项将不起作用。示例如下:
ts
/**
* --strictNullChecks=true
* --strictPropertyInitialization=true
*/
class A {
// 正确
a: number = 0;
// 正确,在构造函数中初始化
b: number;
// 错误!未初始化
c: number;
constructor() {
this.b = 0;
}
}
在此例中,类A的成员变量a在声明时进行了初始化,成员变量b在构造函数中进行了初始化,只有成员变量c始终没有进行初始化,因此将产生未初始化的编译错误。
若启用了"--strictPropertyInitialization"编译选项并且仅在构造函数中对成员变量进行了初始化操作,那么需要在构造函数中直接进行赋值操作。如果通过在构造函数中调用某个方法,进而在该方法中间接地初始化成员变量,那么编译器将无法检测到该初始化操作,因此会产生编译错误。示例如下:
ts
/**
* --strictNullChecks=true
* --strictPropertyInitialization=true
*/
class A {
// 编译错误!未初始化
a: number;
init() {
this.a = 0;
}
constructor() {
this.init();
}
}
此例中,我们在构造函数中调用了init方法对成员变量a进行了初始化,但是编译器却无法检测到成员变量a已经被初始化。
在一些场景中,我们确实想要通过调用某些方法来初始化类的成员变量。这时可以使用非空类型断言"!"来通知编译器该成员变量已经进行初始化,以此来避免产生编译错误。示例如下:
ts
/**
* --strictNullChecks=true
* --strictPropertyInitialization=true
*/
class A {
a!: number;
// ~
// 非空类型断言
init() {
this.a = 0;
}
constructor() {
this.init();
}
}
15.2.2、readonly属性
在声明类的成员变量时,在成员变量名之前添加readonly修饰符能够将该成员变量声明为只读的。只读成员变量必须在声明时初始化或在构造函数里初始化。示例如下:
ts
class A {
readonly a = 0;
readonly b: number;
readonly c: number; // 编译错误
constructor() {
this.b = 0;
}
}
此例中,只读成员变量a在声明时进行了初始化,只读成员变量b在构造函数中进行了初始化,而只读成员变量c没有进行初始化,因此将产生编译错误。
不管是在类的内部还是外部,都不允许修改只读成员变量的值。例如,下例中对类A的成员变量a的修改将产生编译错误:
ts
class A {
readonly a = 0;
m() {
this.a = 1;
// ~
// 编译错误!不能赋值给 'a',因为它是只读属性
}
}
const obj = new A();
obj.a = 1;
// ~
// 编译错误!不能赋值给 'a',因为它是只读属性
关于类只读成员变量的一个最佳实践是,若类的成员变量不应该被修改,那么应该为其添加readonly修饰符。就算不确定是否允许修改类的某个成员变量,也可以先将该成员变量声明为只读的,当发现需要对该成员变量进行修改时再将readonly修饰符去掉。
15.3、成员函数
成员函数也称作方法,声明成员函数与在对象字面量中声明方法是类似的。示例如下:
ts
class Circle {
radius: number = 1;
area(): number {
return Math.PI * this.radius * this.radius;
}
}
此例中,area是一个成员函数。在成员函数中,需要使用this关键字来引用类的其他成员。
15.4、成员存取器
成员存取器由get和set方法构成,并且会在类中声明一个属性。成员存取器的定义方式与对象字面量中属性存取器的定义方式是完全相同的。
如果一个类属性同时定义了get方法和set方法,那么get方法的返回值类型必须与set方法的参数类型一致,否则将产生错误。示例如下:
ts
class C {
/**
* 正确
*/
private _foo: number = 0;
get foo(): number {
return this._foo;
}
set foo(value: number) {}
/**
* 错误!'get' 和 'set' 存取器必须具有相同的类型
*/
private _bar: string = '';
get bar(): string {
return this._bar;
}
set bar(value: number) {}
}
如果一个类属性同时定义了get方法和set方法,那么get方法和set方法必须具有相同的可访问性。例如,不允许将get方法定义为公有的,而将set方法定义为私有的。下例中,foo属性的存取器方法均为私有的,bar属性的存取器方法均为公有的,因此它们是正确的;但baz属性的get方法是公有的,set方法是私有的,两者不一致会产生编译错误。示例如下:
ts
class C {
/**
* 正确
*/
private _foo: number = 0;
private get foo(): number {
return this._foo;
}
private set foo(value) {}
/**
* 正确
*/
private _bar: number = 0;
public get bar(): number {
return this._bar;
}
public set bar(value) {}
/**
* 错误!'get' 和 'set' 存取器具有不同的可见性
*/
private _baz: number = 0;
public get baz(): number {
return this._baz;
}
private set baz(value) {}
}
存取器是实现数据封装的一种方式,它提供了一层额外的访问控制。类可以将成员变量的访问权限制在类内部,在类外部通过存取器方法来间接地访问成员变量。在存取器方法中,还可以加入额外的访问控制等处理逻辑。示例如下:
ts
class Circle {
private _radius: number = 0;
get radius(): number {
return this._radius;
}
set radius(value: number) {
if (value >= 0) {
this._radius = value;
}
}
}
const circle = new Circle();
circle.radius; // 0
circle.radius = -1;
circle.radius; // 0
circle.radius = 10;
circle.radius; // 10
此例中,定义了radius存取器,它用来控制对私有成员变量_radius的访问。在一些编程语言中,_radius成员变量也称为"backing field"。第3行,在get方法中我们直接返回了_radius的值。第6行,在set方法中会检查传入的值大于0时才赋值给_radius。
15.5、索引成员
类的索引成员会在类的类型中引入索引签名。索引签名包含两种,分别为字符串索引签名和数值索引签名。在实际应用中,定义类的索引成员并不常见。类中所有的属性和方法必须符合字符串索引签名定义的类型。同时,只有当类具有类似数组的行为时,数值索引签名才有意义。
类的索引成员与接口中的索引签名类型成员具有完全相同的语法和语义,这里不再重复。示例如下:
ts
class A {
x: number = 0;
[prop: string]: number;
[prop: number]: number;
}
在类的索引成员上不允许定义可访问性修饰符,如public和private等。
15.6、成员可访问性
成员可访问性定义了类的成员允许在何处被访问。TypeScript为类成员提供了以下三种可访问性修饰符:
- public
- protected
- private
这三种可访问性修饰符是TypeScript语言对JavaScript语言的补充。在JavaScript语言中不支持这三种可访问性修饰符。
15.6.1、public
类的公有成员没有访问限制,可以在当前类的内部、外部以及派生类的内部访问。类的公有成员使用public修饰符标识。示例如下:
ts
class Base {
public a: string = '';
}
class Derived extends Base {
public b() {
return this.a; // 允许访问
}
}
const derived = new Derived();
derived.a; // 允许访问
derived.b(); // 允许访问
在默认情况下,类的所有成员都是公有成员。因此,在定义公有成员时也可以省略public修饰符。例如,下例中的成员变量a和成员函数b都是公有成员:
ts
class Base {
a: string = '';
}
class Derived extends Base {
b() {
return this.a;
}
}
15.6.2、protected
类的受保护成员允许在当前类的内部和派生类的内部访问,但是不允许在当前类的外部访问。类的受保护成员使用protected修饰符标识。
例如,下例中Base类的成员变量x是受保护成员,它允许在Base类的内部被访问,它也允许在Base类的派生类Derived内部被访问。但是,它不允许在类的外部被访问。示例如下:
ts
class Base {
protected x: string = '';
a() {
this.x; // 允许访问
}
}
class Derived extends Base {
b() {
this.x; // 允许访问
}
}
const base = new Base();
base.x; // 不允许访问
15.6.3、private
类的私有成员只允许在当前类的内部被访问,在当前类的外部以及派生类的内部都不允许访问。类的私有成员使用private修饰符标识。
例如,下例中Base类的成员变量x是私有成员,它允许在Base类的内部被访问。但是,它既不允许在Base类的派生类Derived内部被访问,也不允许在Base类的外部被访问。示例如下:
ts
class Base {
private x: string = '';
a() {
this.x; // 允许访问
}
}
class Derived extends Base {
b() {
this.x; // 不允许访问
}
}
const base = new Base();
base.x; // 不允许访问
const derived = new Derived();
derived.x; // 不允许访问
15.6.4、私有字段
2020年1月,ECMAScript标准引入了一个新特性,那就是允许在类中定义私有字段。这意味着JavaScript语言将原生地支持类的私有成员。TypeScript语言也从3.8版本开始支持该特性。在ECMAScript标准中,类的私有字段使用一种新的语法来定义,即在字段标识符前添加一个"#"符号。不论是在定义私有字段时还是在访问私有字段时,都需要在私有字段名前添加一个"#"符号。示例如下:
ts
class Circle {
#radius: number;
constructor() {
this.#radius = 1;
}
}
const circle = new Circle();
circle.#radius; // 不允许访问
此例中,"#radius"定义了一个私有字段radius。不论是在定义私有字段时还是在访问私有字段时,都必须在字段标识符前添加一个"#"符号。
15.7、构造函数
构造函数用于创建和初始化类的实例。当使用new运算符调用一个类时,类的构造函数就会被调用。构造函数以constructor作为函数名。示例如下:
ts
class Circle {
radius: number;
constructor(r: number) {
this.radius = r;
}
}
const c = new Circle(1);
此例第4行,定义了Circle类的构造函数,它接受一个number类型的参数r,并使用参数r的值来初始化radius成员变量。第9行,使用new运算符创建类的实例时构造函数就会被调用。
与普通函数相同,在构造函数中也可以定义可选参数、默认值参数和剩余参数。但是构造函数不允许定义返回值类型,因为构造函数的返回值类型永远为类的实例类型。示例如下:
ts
class A {
constructor(a: number = 0, b?: boolean, ...c: string[]) {}
}
class B {
constructor(): object {}
// ~~~~~~~
// 编译错误!不允许指定构造函数的返回值类型
}
在构造函数上也可以使用可访问性修饰符。它描述的是在何处允许使用该类来创建实例对象。在默认情况下,构造函数是公有的。如果将构造函数设置成私有的,则只允许在类的内部创建该类的对象。例如,下例中Singleton类的构造函数是私有的,因此只允许在Singleton类内部创建该类的实例对象。第15行,在Singleton类外部创建其实例对象时将产生编译错误。示例如下:
ts
class Singleton {
private static instance?: Singleton;
private constructor() {}
static getInstance() {
if (!Singleton.instance) {
// 允许访问
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
new Singleton(); // 编译错误
与函数重载类似,构造函数也支持重载。我们将没有函数体的构造函数声明称为构造函数重载,同时将定义了函数体的构造函数声明称为构造函数实现。构造函数重载可以存在零个或多个,而构造函数实现只能存在一个。示例如下:
ts
class A {
constructor(x: number, y: number);
constructor(s: string);
constructor(xs: number | string, y?: number) {}
}
const a = new A(0, 0);
const b = new A('foo');
15.8、参数成员
TypeScript提供了一种简洁语法能够把构造函数的形式参数声明为类的成员变量,它叫作参数成员。在构造函数参数列表中,为形式参数添加任何一个可访问性修饰符或者readonly修饰符,该形式参数就成了参数成员,进而会被声明为类的成员变量。示例如下:
ts
class A {
constructor(public x: number) {}
}
const a = new A(0);
a.x; // 值为0
此例在类A的构造函数中,参数x是一个参数成员,因此会在类A中声明一个public的成员变量x。第5行,使用实际参数0来实例化类A时会自动将成员变量x的值初始化为0,因此第6行读取成员变量x的值时结果为0。我们不需要在构造函数中使用"this.x = x"来设置成员变量x的值,TypeScript能够自动处理。
上例中,我们使用的是public修饰符。类似地,我们也可以使用其他修饰符来定义参数成员。示例如下:
ts
class A {
constructor(
public x: number,
protected y: number,
private z: number
) {}
}
class B {
constructor(readonly x: number) {}
}
readonly修饰符也可以和任意一个可访问性修饰符结合使用来定义只读的参数成员。示例如下:
ts
class A {
constructor(
public readonly x: number,
protected readonly y: number,
private readonly z: number
) {}
}
15.9、继承
继承是面向对象程序设计的三个基本特征之一,TypeScript中的类也支持继承。在定义类时可以使用extends关键字来指定要继承的类,具体语法如下所示:
ts
class DerivedClass extends BaseClass { }
在该语法中,我们将BaseClass叫作基类,将DerivedClass叫作派生类,派生类继承了基类。有时候,我们也将基类称作父类,将派生类称作子类。
当派生类继承了基类后,就自动继承了基类的非私有成员。例如,下例中Circle类继承了Shape类。因此,Circle类获得了Shape类的color和switchColor公有成员。我们可以在Circle类的实例对象上访问color成员变量和调用switchColor成员函数。示例如下:
ts
class Shape {
color: string = 'black';
switchColor() {
this.color =
this.color === 'black' ? 'white' : 'black';
}
}
class Circle extends Shape {}
const circle = new Circle();
circle.color; // 'black'
circle.switchColor();
circle.color; // 'white'
15.9.1、重写基类成员
在派生类中可以重写基类的成员变量和成员函数。在重写成员变量和成员函数时,需要在派生类中定义与基类中同名的成员变量和成员函数。示例如下:
ts
class Shape {
color: string = 'black';
switchColor() {
this.color =
this.color === 'black' ? 'white' : 'black';
}
}
class Circle extends Shape {
color: string = 'red';
switchColor() {
this.color = this.color === 'red' ? 'green' : 'red';
}
}
const circle = new Circle();
circle.color; // 'red'
circle.switchColor();
circle.color; // 'green'
此例中,Circle类重写了Shape类中的color属性和switchColor方法。第20行,读取circle对象上的color属性时,获取的是重写后的属性。第21行,调用circle对象上的switchColor方法时,调用的是重写后的方法。
在派生类中,可以通过super关键字来访问基类中的非私有成员。当派生类和基类中存在同名的非私有成员时,在派生类中只能通过super关键字来访问基类中的非私有成员,无法使用this关键字来引用基类中的非私有成员。示例如下:
ts
class Shape {
color: string = 'black';
switchColor() {
this.color =
this.color === 'black' ? 'white' : 'black';
}
}
class Circle extends Shape {
switchColor() {
super.switchColor();
console.log(`Color is ${this.color}.`);
}
}
const circle = new Circle();
circle.switchColor();
circle.switchColor();
// 打印:
// Color is white.
// Color is black.
此例第11行,Circle类重写了Shape类的switchColor方法。第12行,使用了super关键字来调用Shape类中的switchColor方法。
若派生类重写了基类中的受保护成员,则可以将该成员的可访问性设置为受保护的或公有的。也就是说,在派生类中只允许放宽基类成员的可访问性。例如,下例中Base类的三个成员变量都是受保护成员。在派生类Derived中不允许将其重写为私有成员。示例如下:
ts
class Base {
protected x: string = '';
protected y: string = '';
protected z: string = '';
}
class Derived extends Base {
// 正确
public x: string = '';
// 正确
protected y: string = '';
// 错误!派生类不能够将基类的受保护成员重写为更严格的可访问性
private z: string = '';
}
由于派生类是基类的子类型,因此在重写基类的成员时需要保证子类型兼容性。示例如下:
ts
class Shape {
color: string = 'black';
switchColor() {
this.color =
this.color === 'black' ? 'white' : 'black';
}
}
class Circle extends Shape {
// 编译错误
// 类型'(color: string) => void'不能赋值给类型'() => void'
switchColor(color: string) {}
}
15.9.2、派生类实例化
在派生类的构造函数中必须调用基类的构造函数,否则将不能正确地实例化派生类。在派生类的构造函数中使用"super()"语句就能够调用基类的构造函数。示例如下:
ts
class Shape {
color: string = 'black';
constructor() {
this.color = 'black';
}
switchColor() {
this.color =
this.color === 'black' ? 'white' : 'black';
}
}
class Circle extends Shape {
radius: number;
constructor() {
super();
this.radius = 1;
}
}
此例第18行,在Circle类的构造函数中调用了基类Shape的构造函数。这样能够保证正确地实例化基类Shape中的成员。若派生类中定义了构造函数,但没有添加"super()"语句,那么将产生编译错误。
在派生类的构造函数中,引用了this的语句必须放在"super()"调用的语句之后,否则将产生编译错误,因为在基类初始化之前访问类的成员可能会产生错误。示例如下:
ts
class Shape {
color: string = 'black';
constructor() {
this.color = 'black';
}
switchColor() {
this.color =
this.color === 'black' ? 'white' : 'black';
}
}
class Circle extends Shape {
radius: number;
constructor() {
this.radius = 1;
// ~~~~
// 编译错误,必须先调用 'super' 再访问 'this'
super();
// 正确
this.radius = 1;
}
}
在实例化派生类时的初始化顺序如下:
- 初始化基类的属性。
- 调用基类的构造函数。
- 初始化派生类的属性。
- 调用派生类的构造函数。
例如,下例中的数字标识与上面的步骤序号是对应的:
ts
class Shape {
color: string = 'black'; // 1
constructor() { // 2
console.log(this.color);
this.color = 'white';
console.log(this.color);
}
}
class Circle extends Shape {
radius: number = 1; // 3
constructor() { // 4
super();
console.log(this.radius);
this.radius = 2;
console.log(this.radius);
}
}
const circle = new Circle();
// 输出结果为:
// black
// white
// 1
// 2
15.9.3、单继承
TypeScript中的类仅支持单继承,不支持多继承。也就是说,在extends语句中只能指定一个基类。示例如下:
ts
class A {}
class B {}
class C extends A, B {}
// ~
// 编译错误:类只能继承一个类
15.9.4、接口继承类
TypeScript允许接口继承类。若接口继承了一个类,那么该接口会继承基类中所有成员的类型。例如,下例中接口B继承了类A。因此,接口B中包含了string类型的成员x和方法类型y。示例如下:
ts
class A {
x: string = '';
y(): boolean {
return true;
}
}
interface B extends A {}
declare const b: B;
b.x; // 类型为string
b.y(); // 类型为boolean
在接口继承类时,接口不但会继承基类的公有成员类型,还会继承基类的受保护成员类型和私有成员类型。如果接口从基类继承了非公有成员,那么该接口只能由基类或基类的子类来实现。示例如下:
ts
// 正确,A 可以实现接口 I,因为私有属性和受保护属性源自同一个类 A
class A implements I {
private x: string = '';
protected y: string = '';
}
// 接口 I 能够继承 A 的私有属性和受保护属性
interface I extends A {}
// 正确,B 可以实现接口 I,因为私有属性和受保护属性源自同一个类 A
class B extends A implements I {}
// 错误!C 不是 A 的子类,无法实现 A 的有属性和受保护属性
class C implements I {}
15.10、实现接口
虽然一个类只允许继承一个基类,但是可以实现一个或多个接口。在定义类时,使用implements语句能够声明类所实现的接口。当实现多个接口时,接口名之间使用逗号","分隔。下例中,类C实现了接口A和接口B:
ts
interface A {}
interface B {}
class C implements A, B {}
如果类的定义中声明了要实现的接口,那么这个类就需要实现接口中定义的类型成员。下例中,Circle类声明了要实现Shape 和Color两个接口。因此,在Circle类中需要实现两个接口中定义的类型成员color和area。示例如下:
ts
interface Color {
color: string;
}
interface Shape {
area(): number;
}
class Circle implements Shape, Color {
radius: number = 1;
color: string = 'black';
area(): number {
return Math.PI * this.radius * this.radius;
}
}
15.11、静态成员
类的定义中可以包含静态成员。类的静态成员不属于类的某个实例,而是属于类本身。类的静态成员使用static关键字定义,并且只允许通过类名来访问。
例如,下例中Circle类定义了静态成员变量version,它只允许通过类名Circle进行访问:
ts
class Circle {
static version: string = '1.0';
}
// 正确,结果为 '1.0'
const version = Circle.version;
const circle = new Circle();
circle.version;
// ~~~~~~~
// 编译错误!'version' 属性是 'Circle' 类的静态属性
15.11.1、静态成员可访问性
类的静态成员也可以定义不同的可访问性,如public、private和protected。
类的public静态成员对访问没有限制,可以在当前类的内部、外部以及派生类的内部访问。示例如下:
ts
class Base {
public static x: string = '';
a() {
// 正确,允许在类内部访问公有静态成员 x
Base.x;
}
}
class Derived extends Base {
b() {
// 正确,允许在派生类内部访问公有静态成员 x
Base.x;
}
}
// 正确,允许在类外部访问公有静态成员 x
Base.x;
类的protected静态成员允许在当前类的内部和派生类的内部访问,但是不允许在当前类的外部访问。示例如下:
ts
class Base {
protected static x: string = '';
a() {
// 正确,允许在类内部访问受保护的静态成员 x
Base.x;
}
}
class Derived extends Base {
b() {
// 正确,允许在派生类内部访问受保护的静态成员 x
Base.x;
}
}
// 错误!不允许在类外部访问受保护的静态成员 x
Base.x;
类的private静态成员只允许在当前类的内部访问。示例如下:
ts
class Base {
private static x: string = '';
a() {
// 正确,允许在类内部访问受保护的静态成员 x
Base.x;
}
}
class Derived extends Base {
b() {
// 错误!不允许在派生类内部访问受保护的静态成员 x
Base.x;
}
}
// 错误!不允许在类外部访问受保护的静态成员 x
Base.x;
15.11.2、继承静态成员
类的public静态成员和protected静态成员也可以被继承。例如,下例中派生类Derived继承了基类Base的静态成员x和y:
ts
class Base {
public static x: string = '';
protected static y: string = '';
}
class Derived extends Base {
b() {
// 继承了基类的静态成员 x
Derived.x;
// 继承了基类的静态成员 y
Derived.y;
}
}
15.12、抽象类和抽象成员
15.12.1、抽象类
定义抽象类时,只需要在class关键字之前添加abstract关键字即可。示例如下:
ts
abstract class A {}
抽象类与具体类的一个重要区别是,抽象类不能被实例化。也就是说,不允许使用new运算符来创建一个抽象类的实例。示例如下:
ts
abstract class A {}
const a = new A();
// ~~~~~~~
// 编译错误!不能创建抽象类的实例
抽象类的作用是作为基类使用,派生类可以继承抽象类。示例如下:
ts
abstract class Base {}
class Derived extends Base {}
const derived = new Derived();
抽象类也可以继承其他抽象类。示例如下:
ts
abstract class Base {}
abstract class Derived extends Base {}
此例中,基类和派生类都是抽象类,它们都不能被实例化。
抽象类中允许(通常)包含抽象成员,也允许包含非抽象成员。示例如下:
ts
abstract class Base {
abstract a: string;
b: string = '';
}
15.12.2、抽象成员
在抽象类中允许声明抽象成员,抽象成员不允许包含具体实现代码。示例如下:
ts
// 以下用法均为正确用法
abstract class A {
abstract a: string;
abstract b: number = 0;
abstract method(): string;
abstract get accessor(): string;
abstract set accessor(value: string);
}
abstract class B {
// 编译错误!抽象方法不能带有具体实现
abstract method() {}
// 编译错误!抽象存取器不能带有具体实现
abstract get c(): string { return ''; };
abstract set c(value: string) {};
}
如果一个具体类继承了抽象类,那么在具体的派生类中必须实现抽象类基类中的所有抽象成员。因此,抽象类中的抽象成员不能声明为private,否则将无法在派生类中实现该成员。示例如下:
ts
abstract class Base {
abstract a: string;
abstract get accessor(): string;
abstract set accessor(value: string);
abstract method(): boolean;
}
class Derived extends Base {
// 实现抽象属性 a
a: string = '';
// 实现抽象存取器accessor
private _accessor: string = '';
get accessor(): string {
return this._accessor;
}
set accessor(value: string) {
this._accessor = value;
}
// 实现抽象方法 method
method(): boolean {
return true;
}
}
若没有正确地在具体的派生类中实现抽象成员,将产生编译错误。
15.13、this类型
在类中存在一种特殊的this类型,它表示当前this值的类型。我们可以在类的非静态成员的类型注解中使用this类型。例如,下例中add()方法和subtract()方法的返回值类型为this类型。第20行,我们可以链式调用add()方法和subtract()方法,因为它们返回的是当前实例对象。示例如下:
ts
class Counter {
private count: number = 0;
public add(): this {
this.count++;
return this;
}
public subtract(): this {
this.count--;
return this;
}
public getResult(): number {
return this.count;
}
}
const counter = new Counter();
counter
.add()
.add()
.subtract()
.getResult(); // 结果为1
需要强调的是,this类型是动态的,表示当前this值的类型。当前this值的类型不一定是引用了this类型的那个类,该差别主要体现在类之间有继承关系的时候。示例如下:
ts
class A {
foo(): this {
return this;
}
}
class B extends A {
bar(): this {
return this;
}
}
const b = new B();
const x = b.bar().foo();
// ~
// 类型为B
此例中,foo方法和bar方法的返回值类型都是this类型,且B继承了A。第14行,通过B类的实例来调用foo方法和bar方法时,返回值类型都是B类的实例类型。
注意,this类型不允许应用于类的静态成员。示例如下:
ts
class A {
static a: this;
// ~~~~
// 编译错误! 'this' 类型只能用于类的非静态成员
}
15.14、类类型
类声明将会引入一个新的命名类型,即与类同名的类类型。类类型表示类的实例类型,它由类的实例成员类型构成。例如,下例中Circle类声明同时也定义了Circle类类型,该类型包含number类型的radius属性和函数类型的area属性。该类类型与CircleType接口表示的对象类型是相同的类型。示例如下:
ts
class Circle {
radius: number;
area(): number {
return Math.PI * this.radius * this.radius;
}
}
interface CircleType {
radius: number;
area(): number;
}
// 正确
const a: Circle = new Circle();
// 正确
const b: CircleType = new Circle();
在定义一个类时,实际上我们定义了一个构造函数。随后,我们可以使用new运算符和该构造函数来创建类的实例。我们可以将该类型称作类的构造函数类型,在该类型中也包含了类的静态成员类型。例如,下例中常量a的类型是类类型A,也就是我们经常提到的类的实例类型。常量b的类型是类的构造函数类型,我们使用了包含构造签名的接口表示该类型,并将类A赋值给了常量b。不难发现,类的静态成员x是类构造函数类型的一部分。示例如下:
ts
class A {
static x: number = 0;
y: number = 0;
}
// 类类型,即实例类型
const a: A = new A();
interface AConstructor {
new (): A;
x: number;
}
// 类构造函数类型
const b: AConstructor = A;