跟踪 TypeScript 中的参数类型

Keep track of argument types in TypeScript

我有一个在 TypeScript 中为我的一些实体实现的构建器模式。这是其中之一(为简单起见已删除)以及 in the playground:

type Shape = any;
type Slide = any;
type Animation = any;

export class SlideBuilder {

  private slide: Slide;

  public static start() { return new SlideBuilder(); }

  public withShape(name: string, shape: Shape): this {
    this.slide.addShape(name, shape);
    return this;
  }

  public withAnimation(name: string, animation: Animation): this {
    this.slide.addAnimation(name, animation);
    return this;
  }

  public withOrder(shape: string, animations: string[]) {
    this.slide.addOrder(shape, animations);
    return this;
  }
}

SlideBuilder
  .start()
  .withShape("Hello World", {})
  .withAnimation("Animation1", {})
  .withAnimation("Animation2", {})
  .withOrder("Could be 'Hello World' only", ["Could be 'Animation1' or 'Animation2' only"])

问题是,我想添加一种可能性来检查 withOrder 是否已使用正确的参数调用,这些参数已传递给 withShapewithAnimation .

我已经尝试将泛型类型添加到 class,例如:

export class SlideBuilder<S, A> {
  withShape(name: S, shape: Shape)
  withAnimation(name: A, animation: Animation)
  withOrder(shape: S, animation: A[])
}

但是我找不到一种方法来跟踪每个调用,比如将调用中的每个类型收集到联合类型中。我知道我需要以某种方式指定 withOrder(shape: S1 | S2 | S3 | ... | Sn) 其中 Sn 是来自 withShape 调用的类型,但实际如何实现它?

这是一个很好的问题,回答起来很愉快!

我们如何让编译器跟踪 class 实例的方法在实例生命周期内收到的所有参数?

哇!这是一个很大的问题!起初我不确定这是否可能。

以下是编译器在 class 实例的生命周期内必须执行的操作:

  • 在每次方法调用时,添加实例已收到的参数集。
  • 将这些参数分组,以便我们稍后可以对它们进行类型检查。

我们开始...

回答

以下方法很复杂,我只提供了方法签名。我还将那些签名简化为可以表达想法的最低要求。方法实现将相对直接供您提供。

该方法使用累加器类型来跟踪参数类型。这些累加器类型类似于我们将在 Array.reduce 函数中使用的累加器对象。

这里是the playground link和代码:

type TrackShapes<TSlideBuilder, TNextShape> = 
  TSlideBuilder extends SlideBuilder<infer TShapes, infer TAnimations> 
  ? SlideBuilder<TShapes | TNextShape, TAnimations> 
  : never;

type TrackAnimations<TSlideBuilder, TNextAnimation> = 
  TSlideBuilder extends SlideBuilder<infer TShapes, infer TAnimations> 
  ? SlideBuilder<TShapes, TAnimations | TNextAnimation> 
  : never;

export class SlideBuilder<TShape, TAnimation> {

  public static start(): SlideBuilder<never, never> {
    return new SlideBuilder<never, never>();
  };

  public withShape<TNext extends string>(name: TNext): TrackShapes<this, TNext> {
      throw new Error('TODO Implement withShape.');
  }

  public withAnimation<TNext extends string>(name: TNext): TrackAnimations<this, TNext> {
      throw new Error('TODO Implement withAnimation.');
  }

  public withOrder(shape: TShape, animation: TAnimation[]): this {
    throw new Error('TODO Implement withOrder.');
  }
}

那里发生了什么事?

我们为 SlideBuilder 定义了两种累加器类型。这些接收现有的 SlideBuilderinfer 其形状和动画类型,使用类型联合来扩展适当的泛型类型,然后 return SlideBuilder。这是答案中最高级的部分。

然后在 start 中,我们使用 neverSlideBuilder 初始化为零(可以这么说)。这很有用,因为 T | never 的并集是 T(类似于 5 + 0 = 5)。

现在对 withShapewithAnimation 的每次调用都使用适当的累加器作为其 return 类型。这意味着每次调用都会适当地扩大类型并将参数分类到适当的桶中!

请注意 withShapewithAnimation 泛型 extend string。这将类型限制为 string。它还可以防止将字符串文字类型扩大到 string。这意味着调用者不需要使用 as const,因此提供了更友好的 API.

结果呢?我们 "keep track of" 参数类型!这里有一些测试表明它如何满足要求。

测试用例

// Passes type checking.
SlideBuilder
  .start()
  .withShape("Shape1")
  .withAnimation('Animation1')
  .withOrder("Shape1", ["Animation1"])

// Passes type checking.
SlideBuilder
  .start()
  .withShape("Shape1")
  .withAnimation('Animation1')
  .withAnimation('Animation2')
  .withOrder("Shape1", ["Animation1", "Animation2"])

// Fails type checking.
SlideBuilder
  .start()
  .withShape("Shape1")
  .withAnimation('Animation1')
  .withAnimation('Animation2')
  .withOrder("Foo", ["Animation1", "Animation2"])

// Fails type checking.
SlideBuilder
  .start()
  .withShape("Shape1")
  .withAnimation('Animation1')
  .withAnimation('Animation2')
  .withOrder("Shape1", ["Foo", "Animation2"])

答案的演变

最后,这里有一些 playground 链接显示了这个答案的演变:

Playground Link 显示仅支持形状且需要 as const.

的初始解决方案

Playground Link 将动画带入 class 并且仍在使用 as const

Playground Link 不再需要 as const 并提供几乎完成的解决方案。