Search code examples
typescripttypesbuilder

Keep track of argument types in TypeScript


I have a Builder pattern implemented in TypeScript for some of my entities. Here is one of them (stripped for simplicity) and also 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"])

The thing is, I want to add a possibility to type check that withOrder has been called with the right parameters, parameters that have been already passed to withShape or withAnimation.

I've already tried adding generic types to the class, like:

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

But I couldn't find a way to keep track each call, like collecting each type from the call into union type. I understand that I need to specify somehow withOrder(shape: S1 | S2 | S3 | ... | Sn) where Sn is a type from withShape call, but how actually implement it?


Solution

  • This is a wonderful question that was enjoyable to answer!

    How do we make the compiler track all of the arguments that a class instance's methods have received over the life of the instance?

    Whew! That is a big ask! I was not sure at first whether it was possible.

    Here is what the compiler must do over the lifetime of the class instance:

    • On each method call, add to the set of arguments that the instance has received.
    • Group those arguments, so that we can type check with them later.

    Here we go...

    Answer

    The following approach is complicated enough that I have provided only the method signatures. I have also simplified those signatures to the minimum requirements that can express the idea. The method implementations will be relatively straight forward for you to provide.

    The approach uses accumulator types to keep track of the argument types. These accumulator types are similar to the accumulator objects that we would use in an Array.reduce function.

    Here is the playground link and the code:

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

    What is going on there?

    We define two accumulator types for the SlideBuilder. These receive an existing SlideBuilder, infer its shape and animation types, use a type union to widen the appropriate generic type, and then return the SlideBuilder. This is the most advanced part of the answer.

    Then inside start, we use never to initialize the SlideBuilder at zero (so to speak). This is useful because the union of T | never is T (similarly to how 5 + 0 = 5).

    Now each call to withShape and withAnimation uses the appropriate accumulator as its return type. That means that each call widens the type appropriately and categorizes the argument in the appropriate bucket!

    Note that the withShape and withAnimation generics extend string. This constrains the type to string. It also prevents widening the string literal type to string. That means callers do not need to use as const and thus provides a friendlier API.

    The result? We "keep track of" argument types! Here are some tests that show how it meets the requirements.

    Test Cases

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

    Evolution of the Answer

    Finally, here are some playground links that show the evolution of this answer:

    Playground Link Shows the initial solution that only supports shapes and requires as const.

    Playground Link Brings the animations into the class and is still using as const.

    Playground Link Removes the need for as const and provides an almost finished solution.