I would like a builder pattern where calling certain methods lock me out of other paths. The following correctly fails at runtime, but I would like to be able to write some version of it which fails to type-check on the erroneous final line.
const toppings = Symbol();
const burgerBuilder = {
[toppings]: [] as string[],
withMayo() {
const {withMayo, ..._this} = this;
return {..._this, [toppings]: [...this[toppings], 'mayo']};
},
withKetchup() {
const {withKetchup, ..._this} = this;
return {..._this, [toppings]: [...this[toppings], 'ketchup']};
},
order() {
return `bun-patty-${this[toppings].map(topping => `${topping}-`).join('')}bun`
}
};
console.log(burgerBuilder.order()); // [LOG]: "bun-patty-bun"
console.log(burgerBuilder.withMayo().order()); // [LOG]: "bun-patty-mayo-bun"
console.log(burgerBuilder.withMayo().withKetchup().order()); // [LOG]: "bun-patty-mayo-ketchup-bun"
console.log(burgerBuilder.withMayo().withKetchup().withMayo().order()); // [ERR]: burgerBuilder.withMayo(...).withKetchup(...).withMayo is not a function
I am not sure there is a well-known name for this, given the fact that it would work only with dynamically typed languages.
If I understand correctly, you want a Builder which methods can only be used once. Actually, we could have a more complex exclusion system, e.g. let's say if you also forbid the combination of "mustard" once "mayo" is added.
To summarize, the available "withXXX" methods of the Builder evolve along the path / order of their calls.
The implementation effectively strips the current method from the returned object. As you may have already figured out, TypeScript is able to understand this, but only for the immediate usage: right after using .withMayo()
method, we cannot call again .withMayo()
:
// @ts-expect-error
burgerBuilder.withMayo().withMayo().order(); // Property 'withMayo' does not exist on type '{ [toppings]: string[]; withKetchup(): { [toppings]: string[]; withMayo(): ...; order(): string; }; order(): string; }'.
~~~~~~~~
Unfortunately, this does not work once we use another method in between: we can correctly call the .withKetchup()
method, but then TS allows us to call again .withMayo()
!
burgerBuilder.withMayo().withKetchup().withMayo().order(); // Should not have been okay...
This is because withKetchup
method uses this
, and TS statically infers it to be typeof burgerBuilder
, which therefore includes withMayo
.
In order to achieve the method omission through the different calls, we must tell TS that this
is no longer the full burgerBuilder type.
There is a slight difficulty when trying to do so, because we would need to refer to the burgerBuilder type within itself (recursive typing), which TS cannot handle (and therefore infers it as any
instead).
We can define the minimal interface beforehand:
interface BurgerBuilder {
[toppings]: string[];
order: () => string;
}
or split the definition of burgerBuilder
in 2, in a mixin-like way, to benefit from TS inference:
const burgerBuilderBase = {
[toppings]: [] as string[],
order() {
return `bun-patty-${this[toppings].map(topping => `${topping}-`).join('')}bun`
}
};
const burgerBuilder = {
...burgerBuilderBase,
withMayo<T extends typeof burgerBuilderBase & { withMayo: unknown }>(this: T) {
const {withMayo, ..._this} = this;
return {..._this, [toppings]: [...this[toppings], 'mayo']};
},
withKetchup<T extends typeof burgerBuilderBase & { withKetchup: unknown }>(this: T) {
const {withKetchup, ..._this} = this;
return {..._this, [toppings]: [...this[toppings], 'ketchup']};
}
};
With this, TS now understands that the object (which is passed as this) evolves through the method calls, and we get the expected compile-time error:
// @ts-expect-error
burgerBuilder.withMayo().withKetchup().withMayo().order(); // Property 'withMayo' does not exist on type 'Omit<Omit<{ withMayo<T extends { [toppings]: string[]; order(): string; } & { withMayo: unknown; }>(this: T): Omit<T, "withMayo"> & { [toppings]: string[]; }; withKetchup<T extends { [toppings]: string[]; order(): string; } & { ...; }>(this: T): Omit<...> & { ...; }; [toppings]: string[]; order(): string; }, "withMa...'.
~~~~~~~~