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?
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:
Here we go...
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.');
}
}
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.
// 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"])
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.