深入理解 TypeScript
请支持本书:购买捐赠
(广告,请勿屏蔽。)

18 将类作为值的类型



本章中,我们将探讨将类作为值

18.1 特定类的类型

考虑以下类

class Point {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

此函数接受一个类并创建它的一个实例

function createPoint(PointClass: ???, x: number, y: number) {
  return new PointClass(x, y);
}

如果我们希望参数 PointClassPoint 或其子类,我们应该使用什么类型?

18.2 类型运算符 typeof

§7.7 “两种语言级别:动态与静态” 中,我们探讨了 TypeScript 的两种语言级别

Point 创建了两个东西

根据我们提到 Point 的位置,它意味着不同的东西。这就是为什么我们不能对 PointClass 使用类型 Point 的原因:它匹配类 Point 的*实例*,而不是类 Point 本身。

相反,我们需要使用类型运算符 typeof(TypeScript 语法中的另一个部分,它也存在于 JavaScript 中)。typeof v 代表动态(!) 值 v 的类型。

function createPoint(PointClass: typeof Point, x: number, y: number) { // (A)
  return new PointClass(x, y);
}

// %inferred-type: Point
const point = createPoint(Point, 3, 6);
assert.ok(point instanceof Point);

18.2.1 构造函数类型字面量

构造函数类型字面量是一个带有前缀 new 的函数类型字面量(第 A 行)。该前缀表示 PointClass 是一个必须通过 new 调用的函数。

function createPoint(
  PointClass: new (x: number, y: number) => Point, // (A)
  x: number, y: number
) {
  return new PointClass(x, y);
}

18.2.2 具有构造签名的对象类型字面量

回想一下,接口和对象字面量类型 (OLT) 的成员 包括方法签名和调用签名。调用签名使接口和 OLT 能够描述函数。

类似地,*构造签名*使接口和 OLT 能够描述构造函数。它们看起来像调用签名,但添加了前缀 new。在下一个示例中,PointClass 具有一个带有构造签名的对象字面量类型

function createPoint(
  PointClass: {new (x: number, y: number): Point},
  x: number, y: number
) {
  return new PointClass(x, y);
}

18.3 类的泛型类型:Class<T>

凭借我们获得的知识,我们现在可以通过引入类型参数 T 来为作为值的类创建一个泛型类型

type Class<T> = new (...args: any[]) => T;

我们也可以使用接口而不是类型别名

interface Class<T> {
  new(...args: any[]): T;
}

Class<T> 是一种类的类型,其实例与类型 T 匹配。

18.3.1 示例:创建实例

Class<T> 使我们能够编写 createPoint() 的泛型版本

function createInstance<T>(AnyClass: Class<T>, ...args: any[]): T {
  return new AnyClass(...args);
}

createInstance() 的使用方法如下

class Person {
  constructor(public name: string) {}
}

// %inferred-type: Person
const jane = createInstance(Person, 'Jane');

createInstance() 是通过函数实现的 new 运算符。

18.3.2 示例:使用运行时检查进行类型转换

我们可以使用 Class<T> 来实现类型转换

function cast<T>(AnyClass: Class<T>, obj: any): T {
  if (! (obj instanceof AnyClass)) {
    throw new Error(`Not an instance of ${AnyClass.name}: ${obj}`)
  }
  return obj;
}

使用 cast(),我们可以将值的类型更改为更具体的类型。这在运行时也是安全的,因为我们既静态地更改了类型,又执行了动态检查。以下代码提供了一个示例

function parseObject(jsonObjectStr: string): Object {
  // %inferred-type: any
  const parsed = JSON.parse(jsonObjectStr);
  return cast(Object, parsed);
}

18.3.3 示例:运行时类型安全的 Map

Class<T>cast() 的一个用例是类型安全的 Map

class TypeSafeMap {
  #data = new Map<any, any>();
  get<T>(key: Class<T>) {
    const value = this.#data.get(key);
    return cast(key, value);
  }
  set<T>(key: Class<T>, value: T): this {
    cast(key, value); // runtime check
    this.#data.set(key, value);
    return this;
  }
  has(key: any) {
    return this.#data.has(key);
  }
}

TypeSafeMap 中每个条目的键都是一个类。该类确定条目值的静态类型,并在运行时用于检查。

这是 TypeSafeMap 的实际应用

const map = new TypeSafeMap();

map.set(RegExp, /abc/);

// %inferred-type: RegExp
const re = map.get(RegExp);

// Static and dynamic error!
assert.throws(
  // @ts-expect-error: Argument of type '"abc"' is not assignable
  // to parameter of type 'Date'.
  () => map.set(Date, 'abc'));

18.3.4 陷阱:Class<T> 不匹配抽象类

当需要 Class<T> 时,我们不能使用抽象类

abstract class Shape {
}
class Circle extends Shape {
    // ···
}

// @ts-expect-error: Type 'typeof Shape' is not assignable to type
// 'Class<Shape>'.
//   Cannot assign an abstract constructor type to a non-abstract
//   constructor type. (2322)
const shapeClasses1: Array<Class<Shape>> = [Circle, Shape];

为什么?理由是构造函数类型字面量和构造签名应该只用于可以实际 new 调用的值(包含更多信息的 GitHub 问题)。

这是一种解决方法

type Class2<T> = Function & {prototype: T};

const shapeClasses2: Array<Class2<Shape>> = [Circle, Shape];

这种方法的缺点