Отслеживайте типы аргументов в TypeScript - PullRequest
4 голосов
/ 02 мая 2020

У меня есть шаблон Builder, реализованный в TypeScript для некоторых из моих сущностей. Вот один из них (лишен простоты), а также на детской площадке :

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 было вызывается с правильными параметрами, параметрами, которые уже были переданы в withShape или withAnimation.

Я уже пытался добавить типы generi c в класс, например:

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, но как на самом деле это реализовать?

1 Ответ

3 голосов
/ 02 мая 2020

Это замечательный вопрос, на который было приятно ответить!

Как мы заставим компилятор отслеживать все аргументы, которые методы экземпляра класса получили за время существования экземпляра?

Вот так! Это большая просьба! Сначала я не был уверен, возможно ли это.

Вот что должен делать компилятор за время существования экземпляра класса:

  • При каждом вызове метода добавляйте к набору аргументов, полученных экземпляром.
  • Сгруппируйте эти аргументы, чтобы мы могли позже проверить их.

Здесь мы go ...

Ответ

Следующий подход достаточно сложно, что я предоставил только сигнатуры метода. Я также упростил эти подписи до минимальных требований, которые могут express идея. Реализации метода будут относительно простыми для вас.

Подход использует типы аккумуляторов для отслеживания типов аргументов. Эти типы аккумуляторов похожи на объекты-аккумуляторы, которые мы использовали бы в функции Array.reduce.

Здесь ссылка на игровую площадку и код:

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. Они получают существующие SlideBuilder, infer формы и типы анимации, используют объединение типов, чтобы расширить соответствующий тип generi c, а затем возвращают SlideBuilder. Это самая продвинутая часть ответа.

Затем внутри start мы используем never, чтобы инициализировать SlideBuilder нулями (так сказать). Это полезно, потому что объединение T | never равно T (аналогично тому, как 5 + 0 = 5).

Теперь каждый вызов withShape и withAnimation использует соответствующий аккумулятор в качестве типа возврата. Это означает, что каждый вызов соответствующим образом расширяет тип и классифицирует аргумент в соответствующем сегменте!

Обратите внимание, что withShape и withAnimation generics extend string. Это ограничивает тип до string. Это также предотвращает расширение строкового литерала до string. Это означает, что вызывающим абонентам не нужно использовать as const и, следовательно, предоставляется более дружественный API.

Результат? Мы "отслеживаем" типы аргументов! Вот некоторые тесты, которые показывают, как он соответствует требованиям.

Тестовые случаи

// 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 Link Показывает исходное решение, которое поддерживает только фигуры и требует as const.

Playground Link Приносит анимацию в класс и до сих пор использует as const.

Playground Link Устраняет необходимость в as const и обеспечивает почти готовое решение.

...