Búsqueda de sitios web

Lleva un registro de los tipos de argumentos en TypeScript


Tengo un patrón Builder implementado en TypeScript para algunas de mis entidades. Aquí hay una de ellas (simplificada por simplicidad) y también en el 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"])

La cuestión es que quiero agregar la posibilidad de verificar mediante tipos que withOrder se haya llamado con los parámetros correctos, parámetros que ya se hayan pasado a withShape o withAnimation.

Ya he intentado agregar tipos genéricos a la clase, como:

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

Pero no pude encontrar una manera de hacer un seguimiento de cada llamada, como recolectar cada tipo de la llamada en un tipo unión. Entiendo que necesito especificar de alguna manera withOrder(shape: S1 | S2 | S3 | ... | Sn) donde Sn es un tipo de la llamada withShape, pero ¿cómo implementarlo realmente?

Respuesta #1

¡Esta es una pregunta maravillosa que fue agradable de responder!

¿Cómo hacemos que el compilador rastree todos los argumentos que los métodos de una instancia de clase han recibido a lo largo de la vida de la instancia?

¡Uf! ¡Eso es mucho pedir! Al principio no estaba seguro de si era posible.

Esto es lo que el compilador debe hacer durante la vida útil de la instancia de la clase:

  • En cada llamada al método, añade al conjunto de argumentos que la instancia ha recibido.
  • Agrupa esos argumentos, para que podamos verificar su tipo con ellos más tarde.

Aquí vamos...

Respuesta

El siguiente enfoque es lo suficientemente complicado como para que solo haya proporcionado las firmas de los métodos. También he simplificado esas firmas a los requisitos mínimos que pueden expresar la idea. Las implementaciones de los métodos serán relativamente sencillas de proporcionar para ti.

El enfoque utiliza tipos de acumulador para realizar un seguimiento de los tipos de argumentos. Estos tipos de acumulador son similares a los objetos acumuladores que usaríamos en una función Array.reduce.

Aquí está el enlace al patio de juegos y el código:

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.');
  }
}

¿Qué está pasando allí?

Definimos dos tipos de acumuladores para el SlideBuilder. Estos reciben un SlideBuilder existente, infer su forma y tipos de animación, usan una unión de tipos para ampliar el tipo genérico correspondiente y luego devuelven el SlideBuilder. Esta es la parte más avanzada de la respuesta.

Luego, dentro de start, usamos never para inicializar el SlideBuilder en cero (por así decirlo). Esto es útil porque la unión de T | never es T (de manera similar a cómo 5 + 0=5).

Ahora cada llamada a withShape y withAnimation utiliza el acumulador apropiado como su tipo de retorno. ¡Eso significa que cada llamada amplía el tipo adecuadamente y categoriza el argumento en el grupo correspondiente!

Tenga en cuenta que los genéricos withShape y withAnimation extend string. Esto restringe el tipo a string. También evita ampliar el tipo literal de cadena a string. Eso significa que los usuarios no necesitan usar as const y, por lo tanto, proporciona una API más amigable.

¿El resultado? ¡Nosotros "hacemos un seguimiento de" los tipos de argumentos! Aquí hay algunas pruebas que muestran cómo cumple con los requisitos.

Casos de prueba

// 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"])

Evolución de la Respuesta

Finalmente, aquí hay algunos enlaces de playground que muestran la evolución de esta respuesta:

El enlace del playground muestra la solución inicial que solo admite formas y requiere as const.

El enlace de Playground lleva las animaciones a la clase y todavía está usando as const.

Playground Link elimina la necesidad de as const y proporciona una solución casi terminada.

Artículos relacionados