I recently constructed a Builder class, and I realized that some of the fields should be mandatory, before tying up the builder chain with the final execute().
I figured this check could be done statically at compile-time, and as such I came up with this solution (simplified for the sake of the example):
interface abc {
a: string; // required
b: string; // required
c: string; // optional
}
class Builder<T = void> {
protected state: T = {} as any; // necessary ugly cast
public a(aa: string): Builder<T & Pick<abc, 'a'>> {
Object.defineProperty(this.state, 'a', {
value: aa,
writable: true,
});
return this as unknown as Builder<T & Pick<abc, 'a'>>;
}
public b(bb: string): Builder<T & Pick<abc, 'b'>> {
Object.defineProperty(this.state, 'b', {
value: bb,
writable: true,
});
return this as unknown as Builder<T & Pick<abc, 'b'>>;
}
public c(cc: string): Builder<T & Pick<abc, 'c'>> {
Object.defineProperty(this.state, 'c', {
value: cc,
writable: true,
});
return this as unknown as Builder<T & Pick<abc, 'c'>>;
}
public execute(this: Builder<Pick<abc, 'a' | 'b'>>) {
return this.state;
}
}
Now, when I do this:
new Builder().a('foo').c('bar').execute();
I get the error I desire, which is: The 'this' context of type ... is not assignable to method's 'this' of type ..., which is exactly what I wanted.
However, when I compile this typescript code and import the build files in another project, I don't get this error anymore.
This is the compiled type definition:
interface abc {
a: string;
b: string;
c: string;
}
declare class Builder<T> {
protected state: T;
a(aa: string): Builder<T & Pick<abc, 'a'>>;
b(bb: string): Builder<T & Pick<abc, 'b'>>;
c(cc: string): Builder<T & Pick<abc, 'c'>>;
execute(this: Builder<Pick<abc, 'a' | 'b'>>): Pick<abc, "a" | "b">;
}
And here is my tsconfig.json (I've excluded the project paths to stay relevant to the topic):
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"strict": true,
"importHelpers": true,
"declaration": true,
"declarationDir": "dist/esm/types/",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"noImplicitAny": true,
"lib": [
"ESNext",
"webworker",
"DOM"
]
}
}
To add insult to injury, IntelliSense shows me these types when hovering over the variables (although I know this is not necessarily tied to the compiler):
new Builder().execute(); // should be an error
// with the type...
Builder<void>.send(this: Builder<Pick<abc, "a" | "b">>): ...
Is this intended behaviour? Do I need to change tsconfig.json? Am I doing something wrong? If I don't compile the class, then it works perfectly.