类的类型标注(Class Types)

TypeScript 完全支持 ES2015 引入的 class 关键字。和其他 JavaScript 语言特性一样,TypeScript 提供了类型注解和其他语法,允许你表达类与其他类型之间的关系。

类成员

这是一个最基本的类,一个空类:

class Point {}

这个类并没有什么用,所以让我们添加一些成员。


字段

一个字段声明会创建一个公共(public)可写入(writeable)的属性:

class Point {
  x: number;
  y: number;
}

const pt = new Point();
pt.x = 0;
pt.y = 0;

注意:类型注解是可选的,如果没有指定,会隐式的设置为 any

字段可以设置初始值:

class Point {
  x = 0;
  y = 0;
}

const pt = new Point();
console.log(`${pt.x}, ${pt.y}`); // 输出 0, 0

就像 const let var ,一个类属性的初始值会被用于推断它的类型。

const pt = new Point();
pt.x = "0";
// Type 'string' is not assignable to type 'number'.


strictPropertyInitialization 选项

strictPropertyInitialization 选项,是控制严格性的标记,控制了类的字段是否需要在构造器(构造函数)中初始化。如果开启 strictPropertyInitialization ,我们必须要确保每个实例的属性都会初始化,可以在构造函数里初始化或者属性定义时赋值。

class BadGreeter {
  name: string;
  // Property 'name' has no initializer and is not definitely assigned in the constructor.
}
class GoodGreeter {
  name: string;
  // 构造器
  constructor() {
    this.name = "hello";
  }
}

注意,字段需要在构造函数自身进行初始化。TypeScript 不会分析从构造函数调用的方法以检测初始化,因为派生类也许会覆盖这些方法并且无法初始化成员:

class BadGreeter {
  name: string;

  // Property 'name' has no initializer and is not definitely assigned in the constructor.
  setName(): void {
    this.name = '123'
  }

  constructor() {
    this.setName();
  }
}

如果你执意通过构造函数以外的方法来初始化字段(例如,可能有一个外部库正在为您填充类的一部分),你可以使用 赋值断言操作符 !

class OKGreeter {
  // Not initialized, but no error
  name!: string;
}


readonly

字段可以添加一个 readonly 前缀修饰符,这将防止对构造函数之外的字段进行赋值。

class Greeter {
  readonly name: string = "world";
 
  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }
 
  err() {
    this.name = "not ok";
    // Cannot assign to 'name' because it is a read-only property.
  }
}

const g = new Greeter();
g.name = "also not ok";
// Cannot assign to 'name' because it is a read-only property.


构造函数

类的构造函数跟函数非常类似,你可以使用带类型注解的参数、默认值、重载等。

class Point {
  x: number;
  y: number;
 
  // Normal signature with defaults
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
class Point {
  // Overloads
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any, y?: any) {
    // TBD
  }
}

但类构造函数签名与函数签名之间也有一些区别:

  • 构造函数不能有类型参数(关于类型参数,回想下泛型里的内容),这些属于外层的类声明,我们稍后就会学习到。
  • 构造函数不能有返回类型注解,因为总是返回类实例类型

Super 调用

在 JavaScript 中, super 关键字用于访问对象字面量或类的原型([[Prototype]])上的属性,或调用父类的构造函数。

ES6 类(class)可以通过 extends 关键字实现继承,而同时子类必须在 constructor 方法中调用 super() 方法,否则新建实例时会报错。这是因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用 super() 方法,子类就得不到 this 对象。

ES5 类的继承的实质是先创造子类的实例对象 this ,然后再将父类的方法添加到 this 上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到 this 上面(所以必须先调用 super() 方法),然后再用子类的构造函数修改 this

因此 ES6 中,子类的构造函数中,只有调用 super() 之后,才可以使用 this 关键字,否则会报错。这是因为子类实例的构建,基于父类实例,只有 super() 方法才能调用父类实例。同时子类没有定义 constructor 方法, super() 方法会被默认添加。

super这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

super()方法

super 作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次 super() 函数。子类 B 的构造函数之中的 super() ,代表调用父类的构造函数。

class A {}

class B extends A {
  constructor() {
    super();
  }
}

注意, super() 虽然代表了父类 A 的构造函数,但是返回的是子类 B 的实例,即 super() 内部的 this 指的是 B 的实例,因此 super() 在这里相当于 A.prototype.constructor.call(this)

class A {
  constructor() {
    console.log(new.target.name);
  }
}
class B extends A {
  constructor() {
    super();
  }
}
new A() // A
new B() // B

作为函数时, super() 只能用在子类的构造函数之中,用在其他地方就会报错。

class A {}

class B extends A {
  m() {
    super(); // 报错
  }
}


super 对象

super 作为对象时,在普通方法中,指向父类的原型对象,在静态方法中,指向父类。

class A {
  p() {
    return 2;
  }
}

class B extends A {
  constructor() {
    super();
    console.log(super.p()); // 2
  }
}

let b = new B();

由于 super 指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过 super 调用的。ES6 规定,在子类普通方法中通过 super 调用父类的方法时,方法内部的 this 指向当前的子类实例。由于 this 指向子类实例,所以如果通过 super 对某个属性赋值,这时 super 就是 this ,赋值的属性会变成子类实例的属性。

class A {
  constructor() {
    this.p = 2;
  }
}

class B extends A {
  get m() {
    return super.p;
  }
}

let b = new B();
b.m // undefined

如果 super 作为对象,用在静态方法之中,这时 super 将指向父类,而不是父类的原型对象。在子类的静态方法中通过 super 调用父类的方法时,方法内部的 this 指向当前的子类,而不是子类的实例。

class A {
  constructor() {
    this.x = 1;
  }
  static print() {
    console.log(this.x);
  }
}

class B extends A {
  constructor() {
    super();
    this.x = 2;
  }
  static m() {
    super.print();
  }
}

B.x = 3;
B.m() // 3



就像在 JavaScript 中,如果你有一个基类,你需要在使用任何 this. 成员之前,先在构造函数里调用 super()

class Base {
  k = 4;
}
 
class Derived extends Base {
  constructor() {
    // Prints a wrong value in ES5; throws exception in ES6
    console.log(this.k);
    'super' must be called before accessing 'this' in the constructor of a derived class.
    super();
  }
}

忘记调用 super() 是 JavaScript 中一个简单的错误,但是 TypeScript 会在需要的时候提醒你。


方法

类中的函数属性被称为方法。方法跟函数、构造函数一样,使用相同的类型注解。

class Point {
  x = 10;
  y = 10;
 
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

除了标准的类型注解,TypeScript 并没有给方法添加任何新的东西。

注意在一个方法体内,它依然可以通过 this. 访问字段和其他的方法。方法体内一个未限定作用域的变量,总是指向闭包作用域里的内容。

let x: number = 0;
 
class C {
  x: string = "hello";
 
  m() {
    // This is trying to modify 'x' from line 1, not the class property
    x = "world";
    // let x: number
   // Type 'string' is not assignable to type 'number'.
  }
}


Getters / Setter

类也可以有存取器(accessors):

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

TypeScript 对存取器有一些特殊的推断规则:

  • 如果 get 存在而 set 不存在,属性会被自动设置为 readonly
  • 如果 setter 参数的类型没有指定,它会被推断为 getter 的返回类型
  • getters 和 setters 必须有相同的成员可见性(Member Visibility )。

从 TypeScript 4.3 起,存取器在读取和设置的时候可以使用不同的类型。

class Thing {
  _size = 0;
 
  get size(): number {
    return this._size;
  }
 
  set size(value: string | number | boolean) {
    let num = Number(value);
 
    // Don't allow NaN, Infinity, etc 
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
 
    this._size = num;
  }
}


索引签名

类可以声明索引签名,它和对象类型的索引签名是一样的:

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);
 
  check(s: string) {
    return this[s] as boolean;
  }
}

因为索引签名类型也需要捕获方法的类型,这使得并不容易有效的使用这些类型。通常的来说,在其他地方存储索引数据而不是在类实例本身,会更好一些。


类继承

JavaScript 的类可以继承基类。

implements 语句

你可以使用 implements 语句检查一个类是否满足一个特定的 interface 。如果一个类没有正确的实现(implement)它,TypeScript 会报错:

interface Pingable {
  ping(): void;
}
 
class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}
 
class Ball implements Pingable {
  // Class 'Ball' incorrectly implements interface 'Pingable'. Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.
  pong() {
    console.log("pong!");
  }
}

类也可以实现多个接口,比如 class C implements A, B{

注意事项

implements 语句仅仅检查类是否按照接口类型实现,但它并不会改变类的类型或者方法的类型。一个常见的错误就是以为 implements 语句会改变类的类型——然而实际上它并不会:

interface Checkable {
  check(name: string): boolean;
}
 
class NameChecker implements Checkable {
  check(s) {
    Parameter 's' implicitly has an 'any' type.
    // Notice no error here
    return s.toLowercse() === "ok";// any             
  }
}

在这个例子中,我们可能会以为 s 的类型会被 check name: string 参数影响。实际上并没有, implements 语句并不会影响类的内部是如何检查或者类型推断的。

类似的,实现一个有可选属性的接口,并不会创建这个属性:

interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;
// Property 'y' does not exist on type 'C'.


extends 语句

类可以 extend 一个基类。一个派生类有基类所有的属性和方法,还可以定义额外的成员。

class Animal {
  move() {
    console.log("Moving along!");
  }
}
 
class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) { console.log("woof!"); } } } const d = new Dog(); // Base class method
d.move();
// Derived class method
d.woof(3);


覆写属性(Overriding Methods)

一个派生类可以覆写一个基类的字段或属性。你可以使用 super 语法访问基类的方法。TypeScript 强制要求派生类总是它的基类的子类型。

举个例子,这是一个合法的覆写方法的方式:

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }
}
 
const d = new Derived();
d.greet();
d.greet("reader");

派生类需要遵循着它的基类的实现。而且通过一个基类引用指向一个派生类实例,这是非常常见并合法的:

// Alias the derived instance through a base class reference
const b: Base = d;
// No problem
b.greet();

但是如果 Derived 不遵循 Base 的约定实现呢?

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  // Make this parameter required
  greet(name: string) {
  Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'.
  Type '(name: string) => void' is not assignable to type '() => void'.
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}

即便我们忽视错误编译代码,这个例子也会运行错误:

const b: Base = new Derived();
// Crashes because "name" will be undefined
b.greet();


初始化顺序

有些情况下,JavaScript 类初始化的顺序会让你感到很奇怪,让我们看这个例子:

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}
 
class Derived extends Base {
  name = "derived";
}
 
// Prints "base", not "derived"
const d = new Derived();

到底发生了什么呢?

类初始化的顺序,就像在 JavaScript 中定义的那样:

  • 基类字段初始化
  • 基类构造函数运行
  • 派生类字段初始化
  • 派生类构造函数运行

这意味着基类构造函数只能看到它自己的 name 的值,因为此时派生类字段初始化还没有运行。


继承内置类型

注意:如果你不打算继承内置的类型比如 Array Error Map 等或者你的编译目标是 ES6/ES2015 或者更新的版本,你可以跳过这个章节。

在 ES2015 中,当调用 super(...) 的时候,如果构造函数返回了一个对象,会隐式替换 this 的值。所以捕获 super() 可能的返回值并用 this 替换它是非常有必要的。

这就导致,像 Error Array 等子类,也许不会再如你期望的那样运行。这是因为 Error Array 等类似内置对象的构造函数,会使用 ECMAScript 6 的 new.target 调整原型链。然而,在 ECMAScript 5 中,当调用一个构造函数的时候,并没有方法可以确保 new.target 的值。其他的降级编译器默认也会有同样的限制。

对于一个像下面这样的子类:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return "hello " + this.message;
  }
}

你也许可以发现:

  1. 对象的方法可能是 undefined ,所以调用 sayHello 会导致错误
  2. instanceof 失效, (new MsgError())instanceof MsgError 会返回 false

我们推荐,手动的在 super(...) 调用后调整原型:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
 
    // Set the prototype explicitly.
    Object.setPrototypeOf(this, MsgError.prototype);
  }
 
  sayHello() {
    return "hello " + this.message;
  }
}

不过,任何 MsgError 的子类也不得不手动设置原型。如果运行时不支持 Object.setPrototypeOf ,你也许可以使用 __proto__

不幸的是,这些方案并不会能在 IE 10 或者之前的版本正常运行。解决的一个方法是手动拷贝原型中的方法到实例中(就比如 MsgError.prototype this ),但是它自己的原型链依然没有被修复。


成员可见性(Member Visibility)

你可以使用 TypeScript 控制某个方法或者属性是否对类以外的代码可见。

public

类成员默认的可见性为 public ,一个 public 的成员可以在任何地方被获取:

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

因为 public 是默认的可见性修饰符,所以你不需要写它,除非处于格式或者可读性的原因。


protected

protected 成员仅仅对子类可见:

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}
 
class SpecialGreeter extends Greeter {
  public howdy() {
    // OK to access protected member here
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName();

// Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.


受保护成员的公开

派生类需要遵循基类的实现,但是依然可以选择公开拥有更多能力的基类子类型,这就包括让一个 protected 成员变成 public

class Base {
  protected m = 10;
}
class Derived extends Base {
  // No modifier, so default is 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

这里需要注意的是,如果公开不是故意的,在这个派生类中,我们需要小心的拷贝 protected 修饰符。


交叉等级受保护成员访问

不同的 OOP 语言在通过一个基类引用是否可以合法的获取一个 protected 成员是有争议的。

class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Base) {
    other.x = 10;
    // Property 'x' is protected and only accessible through an instance of class 'Derived2'. This is an instance of class 'Base'.
  }
}

在 Java 中,这是合法的,而 C#和 C++认为这段代码是不合法的。

TypeScript 站在 C#和 C++这边。因为 Derived2 x 应该只有从 Derived2 的子类访问才是合法的,而 Derived1 并不是它们中的一个。此外,如果通过 Derived1 访问 x 是不合法的,通过一个基类引用访问也应该是不合法的。


private

private 有点像 protected ,但是不允许访问成员,即便是子类。

class Base {
  private x = 0;
}
const b = new Base();
// Can't access from outside the class
console.log(b.x);
// Property 'x' is private and only accessible within class 'Base'.
class Derived extends Base {
  showX() {
    // Can't access in subclasses
    console.log(this.x);
    // Property 'x' is private and only accessible within class 'Base'.
  }
}

因为 private 成员对派生类并不可见,所以一个派生类也不能增加它的可见性:

class Base {
  private x = 0;
}
class Derived extends Base {
  // Class 'Derived' incorrectly extends base class 'Base'.
  Property 'x' is private in type 'Base' but not in type 'Derived'.
  x = 1;
}


交叉实例私有成员访问

不同的 OOP 语言在关于一个类的不同实例是否可以获取彼此的 private 成员上,也是不一致的。像 Java、C#、C++、Swift 和 PHP 都是允许的,Ruby 是不允许。

TypeScript 允许交叉实例私有成员的获取:

class A {
  private x = 10;
 
  public sameAs(other: A) {
    // No error
    return other.x === this.x;
  }
}


警告(Caveats)

private protected 仅仅在类型检查的时候才会强制生效。

这意味着在 JavaScript 运行时,像 in 或者简单的属性查找,依然可以获取 private 或者 protected 成员。

class MySafe {
  private secretKey = 12345;
}
// In a JavaScript file...
const s = new MySafe();
// Will print 12345
console.log(s.secretKey);

private 允许在类型检查的时候,通过方括号语法进行访问。这让比如单元测试的时候,会更容易访问 private 字段,这也让这些字段是弱私有(soft private)而不是严格的强制私有。

class MySafe {
  private secretKey = 12345;
}
 
const s = new MySafe();
 
// Not allowed during type checking
console.log(s.secretKey);
Property 'secretKey' is private and only accessible within class 'MySafe'.
 
// OK
console.log(s["secretKey"]);

不像 TypeScript 的 private ,JavaScript 的私有字段( # )即便是编译后依然保留私有性,并且不会提供像上面这种方括号获取的方法,这让它们变得强私有(hard private)。

class Dog {
  #barkAmount = 0;
  personality = "happy";

  constructor() {}
}
"use strict";
class Dog {
    #barkAmount = 0;
    personality = "happy";
    constructor() { }
}

当被编译成 ES2021 或者之前的版本,TypeScript 会使用 WeakMaps 替代 # :

"use strict";
var _Dog_barkAmount;
class Dog {
    constructor() {
        _Dog_barkAmount.set(this, 0);
        this.personality = "happy";
    }
}
_Dog_barkAmount = new WeakMap();

如果你需要防止恶意攻击,保护类中的值,你应该使用强私有的机制比如闭包, WeakMaps ,或者私有字段。但是注意,这也会在运行时影响性能。

TypeScript 的官方文档早已更新,但我能找到的中文文档都还停留在比较老的版本。所以对其中新增以及修订较多的一些章节进行了翻译整理。

本篇翻译整理自 TypeScript Handbook 中「Classes 」章节。

本文并不严格按照原文翻译,对部分内容也做了解释补充。


静态成员

类可以有静态成员,静态成员跟类实例没有关系,可以通过类本身访问到:

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x);
MyClass.printX();

静态成员同样可以使用 public protected private 这些可见性修饰符:

class MyClass {
  private static x = 0;
}
console.log(MyClass.x);
// Property 'x' is private and only accessible within class 'MyClass'.

静态成员也可以被继承:

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}


特殊静态名称

类本身是函数,而覆写 Function 原型上的属性通常认为是不安全的,因此不能使用一些固定的静态名称,函数属性像 name length call 不能被用来定义 static 成员:

class S {
  static name = "S!";
  // Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.
}


为什么没有静态类?

TypeScript(和 JavaScript)并没有名为静态类(static class)的结构,但是像 C#和 Java 有。

所谓静态类,指的是作为类的静态成员存在于某个类的内部的类。比如这种:

// java
public class OuterClass {
  private static String a = "1";
  static class InnerClass {
    private int b = 2;
  }
}

静态类之所以存在是因为这些语言强迫所有的数据和函数都要在一个类内部,但这个限制在 TypeScript 中并不存在,所以也没有静态类的需要。一个只有一个单独实例的类,在 JavaScript/TypeScript 中,完全可以使用普通的对象替代。

举个例子,我们不需要一个 static class 语法,因为 TypeScript 中一个常规对象(或者顶级函数)可以实现一样的功能:

// Unnecessary "static" class
class MyStaticClass {
  static doSomething() {}
}
 
// Preferred (alternative 1)
function doSomething() {}
 
// Preferred (alternative 2)
const MyHelperObject = {
  dosomething() {},
};


类静态块

静态块允许你写一系列有自己作用域的语句,也可以获取类里的私有字段。这意味着我们可以安心的写初始化代码:正常书写语句,无变量泄漏,还可以完全获取类中的属性和方法。

class Foo {
    static #count = 0;
 
    get count() {
        return Foo.#count;
    }
 
    static {
        try {
            const lastInstances = loadLastInstances();
            Foo.#count += lastInstances.length;
        }
        catch {}
    }
}


泛型类

类跟接口一样,也可以写泛型。当使用 new 实例化一个泛型类,它的类型参数的推断跟函数调用是同样的方式:

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}

const b = new Box("hello!");
// const b: Box<string>

类跟接口一样也可以使用泛型约束以及默认值。


静态成员中的类型参数

这代码并不合法,但是原因可能并没有那么明显:

class Box<Type> {
  static defaultValue: Type;
  // Static members cannot reference class type parameters.
}

记住类型会被完全抹除,运行时,只有一个 Box.defaultValue 属性槽。这也意味着如果设置 Box<string>.defaultValue 是可以的话,这也会改变 Box<number>.defaultValue ,而这样是不好的。

所以泛型类的静态成员不应该引用类的类型参数。


类运行时的 this

TypeScript 并不会更改 JavaScript 运行时的行为,并且 JavaScript 有时会出现一些奇怪的运行时行为。

就比如 JavaScript 处理 this 就很奇怪:

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};
 
// Prints "obj", not "MyClass"
console.log(obj.getName());

默认情况下,函数中 this 的值取决于函数是如何被调用的。在这个例子中,因为函数通过 obj 被调用,所以 this 的值是 obj 而不是类实例。

这显然不是你所希望的。TypeScript 提供了一些方式缓解或者阻止这种错误。


箭头函数

如果你有一个函数,经常在被调用的时候丢失 this 上下文,使用一个箭头函数或许更好些。

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// Prints "MyClass" instead of crashing
console.log(g());

这里有几点需要注意下:

  • this 的值在运行时是正确的,即使 TypeScript 不检查代码
  • 这会使用更多的内存,因为每一个类实例都会拷贝一遍这个函数。
  • 你不能在派生类使用 super.getName ,因为在原型链中并没有入口可以获取基类方法。


this 参数

在 TypeScript 方法或者函数的定义中,第一个参数且名字为 this 有特殊的含义。该参数会在编译的时候被抹除:

// TypeScript input with 'this' parameter
function fn(this: SomeType, x: number) {
  /* ... */
}
// JavaScript output
function fn(x) {
  /* ... */
}

TypeScript 会检查一个有 this 参数的函数在调用时是否有一个正确的上下文。不像上个例子使用箭头函数,我们可以给方法定义添加一个 this 参数,静态强制方法被正确调用:

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();
 
// Error, would crash
const g = c.getName;
console.log(g());
// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.

这个方法也有一些注意点,正好跟箭头函数相反:

  • JavaScript 调用者依然可能在没有意识到它的时候错误使用类方法
  • 每个类一个函数,而不是每一个类实例一个函数
  • 基类方法定义依然可以通过 super 调用


this 类型

在类中,有一个特殊的名为 this 的类型,会动态的引用当前类的类型,让我们看下它的用法:

class Box {
  contents: string = "";
  set(value: string) {
  // (method) Box.set(value: string): this
    this.contents = value;
    return this;
  }
}

这里,TypeScript 推断 set 的返回类型为 this 而不是 Box 。让我们写一个 Box 的子类:

class ClearableBox extends Box {
  clear() {
    this.contents = "";
  }
}
 
const a = new ClearableBox();
const b = a.set("hello");     
// const b: ClearableBox

你也可以在参数类型注解中使用 this

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

不同于写 other: Box ,如果你有一个派生类,它的 sameAs 方法只接受来自同一个派生类的实例。

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}
 
class DerivedBox extends Box {
  otherContent: string = "?";
}
 
const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
  Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.


基于 this 的类型保护(this-based type guards)

你可以在类和接口的方法返回的位置,使用 this is Type 。当搭配使用类型收窄(举个例子, if 语句),目标对象的类型会被收窄为更具体的 Type

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}
 
class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}
 
class Directory extends FileSystemObject {
  children: FileSystemObject[];
}
 
interface Networked {
  host: string;
}
 
const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");
 
if (fso.isFile()) {
  fso.content;
  // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children;
  // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host;
  // const fso: Networked & FileSystemObject
}

一个常见的基于 this 的类型保护的使用例子,会对一个特定的字段进行懒校验(lazy validation)。举个例子,在这个例子中,当 hasValue 被验证为 true 时,会从类型中移除 undefined

class Box<T> {
  value?: T;
 
  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}
 
const box = new Box();
box.value = "Gameboy";
 
box.value;
//(property) Box.value?: unknown
 
if (box.hasValue()) {
  box.value;
  //(property) value: unknown
}


参数属性

TypeScript 提供了特殊的语法,可以把一个构造函数参数转成一个同名同值的类属性。这些就被称为参数属性(parameter properties)。你可以通过在构造函数参数前添加一个可见性修饰符 public private protected 或者 readonly 来创建参数属性,最后这些类属性字段也会得到这些修饰符:

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // No body necessary
  }
}
const a = new Params(1, 2, 3);
console.log(a.x);
// (property) Params.x: number
console.log(a.z);
// Property 'z' is private and only accessible within class 'Params'.


类表达式(Class Expressions)

类表达式跟类声明非常类似,唯一不同的是类表达式不需要一个名字,尽管我们可以通过绑定的标识符进行引用:

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};

const m = new someClass("Hello, world");  
// const m: someClass<string>


抽象类和成员(abstract Classes and Members)

TypeScript 中,类、方法、字段都可以是抽象的(abstract)。

抽象方法或者抽象字段是不提供实现的。这些成员必须存在在一个抽象类中,这个抽象类也不能直接被实例化。

抽象类的作用是作为子类的基类,让子类实现所有的抽象成员。当一个类没有任何抽象成员,他就会被认为是具体的(concrete)。

让我们看个例子:

abstract class Base {
  abstract getName(): string;
 
  printName() {
    console.log("Hello, " + this.getName());
  }
}
 
const b = new Base();
// Cannot create an instance of an abstract class.

我们不能使用 new 实例 Base 因为它是抽象类。我们需要写一个派生类,并且实现抽象成员。

class Derived extends Base {
  getName() {
    return "world";
  }
}
 
const d = new Derived();
d.printName();

注意,如果我们忘记实现基类的抽象成员,我们会得到一个报错:

class Derived extends Base {
  Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'.
  // forgot to do anything
}


抽象构造签名

有的时候,你希望接受传入可以继承一些抽象类产生一个类的实例的类构造函数。

举个例子,你也许会写这样的代码:

function greet(ctor: typeof Base) {
  const instance = new ctor();
  // Cannot create an instance of an abstract class.
  instance.printName();
}

TypeScript 会报错,告诉你正在尝试实例化一个抽象类。毕竟,根据 greet 的定义,这段代码应该是合法的:

// Bad!
greet(Base);

但如果你写一个函数接受传入一个构造签名:

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);

// Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
// Cannot assign an abstract constructor type to a non-abstract constructor type.

现在 TypeScript 会正确的告诉你,哪一个类构造函数可以被调用, Derived 可以,因为它是具体的,而 Base 是不能的。


类之间的关系

大部分时候,TypeScript 的类跟其他类型一样,会被结构性比较。

举个例子,这两个类可以用于替代彼此,因为它们结构是相等的:

class Point1 {
  x = 0;
  y = 0;
}
 
class Point2 {
  x = 0;
  y = 0;
}

// OK
const p: Point1 = new Point2();

类似的还有,类的子类型之间可以建立关系,即使没有明显的继承:

class Person {
  name: string;
  age: number;
}
 
class Employee {
  name: string;
  age: number;
  salary: number;
}

// OK
const p: Person = new Employee();

这听起来有些简单,但还有一些例子可以看出奇怪的地方。

空类没有任何成员。在一个结构化类型系统中,没有成员的类型通常是任何其他类型的父类型。所以如果你写一个空类(只是举例,你可不要这样做),任何东西都可以用来替换它:

class Empty {}
 
function fn(x: Empty) {
  // can't do anything with 'x', so I won't
}
 
// All OK!
fn(window);
fn({});
fn(fn);

上篇: 工具类型

下篇: 装饰器