Consider the following example:
const sdk = new MySDK();
sdk
.foo('foo1').done<MySDK>() // ideally .done() returns instance of MySDK w/o T
.foo('foo2')
.bar('bar1').done<Foo>() // ideally .done() returns instance of Foo w/o T
.baz('baz1').done<Foo>() // ideally .done() returns instance of Foo w/o T
.done<MySDK>() // ideally .done() returns instance of MySDK w/o T
.finalize();
Is there a way to achieve sync method chaining in Typescript at compile-time without needing to specify the type (but still get method auto-completion in VS Code etc.)? Ideally this would look like:
sdk
.foo('foo1').done()
.foo('foo2')
.bar('bar1').done()
.baz('baz1').done()
.done()
.finalize();
Currently I am manually telling the compiler what the done
method returns at compile-time and using state stored at runtime to determine the caller. The full source:
abstract class Creatable {
creatableStack: MyObectType[];
name: string;
constructor(creatableStack: MyObectType[], name: string) {
console.log(creatableStack, name);
this.creatableStack = creatableStack;
this.name = name;
this.creatableStack.push(this);
}
done<T>(): T {
console.log('done', this.creatableStack);
this.creatableStack.pop();
const previousMyObject = this.creatableStack.at(-1);
return previousMyObject as T;
}
bar(name: string): Bar {
return new Bar(this.creatableStack, name);
}
}
type MyObectType = MySDK | Foo | Bar | Baz;
class Bar extends Creatable {
constructor(creatableStack: MyObectType[], name: string) {
super(creatableStack, name);
}
}
class Foo extends Creatable {
constructor(creatableStack: MyObectType[], name: string) {
super(creatableStack, name);
}
baz(name: string) {
return new Baz(this.creatableStack, name);
}
}
class Baz extends Creatable {
constructor(creatableStack: MyObectType[], name: string) {
super(creatableStack, name);
}
}
class MySDK {
creatableStack: MyObectType[] = [];
constructor() {
this.creatableStack.push(this);
}
foo(name: string): Foo {
return new Foo(this.creatableStack, name);
}
finalize(): MySDK {
console.log('finalize', this.creatableStack);
return this;
}
}
const sdk = new MySDK();
sdk
.foo('foo1').done<MySDK>() // ideally .done() returns instance of MySDK w/o T
.foo('foo2')
.bar('bar1').done<Foo>() // ideally .done() returns instance of Foo w/o T
.baz('baz1').done<Foo>() // ideally .done() returns instance of Foo w/o T
.done<MySDK>() // ideally .done() returns instance of MySDK w/o T
.finalize();
CodePudding user response:
In order for this to work, all of your classes need to be generic in a type that encodes the current state of the stack. One general approach is something like
declare class S<T = undefined> {
push(): S<this>;
pop(): T;
}
where the type parameter T
is the type of the stack before you got here. So when you call push()
, you return S<this>
using the polymorphic this
type to show that you are returning a stack with this
on top, and when you call pop()
you return T
, which is whatever the stack was when you started.
And you'd use it like this:
const s0 = new S();
// const s0: S<undefined>
const s1 = s0.push();
// const s1: S<S<undefined>>;
const s2 = s1.push();
// const s2: S<S<S<undefined>>>
const s3 = s2.pop();
// const s3: S<S<undefined>>
const s4 = s3.pop();
// const s4: S<undefined>
const s5 = s4.pop();
// const s5: undefined
You can see that the stack goes from S
to S<S>
to S<S<S>>
and back to S<S>
, S
, and eventually undefined
.
Adapting your class hierarchy is mostly about adding type parameters everywhere:
declare abstract class Creatable<T> {
creatableStack: MyObectType[];
name: string;
constructor(creatableStack: MyObectType[], name: string);
done(): T; // <-- this is popping T off the stack
bar(name: string): Bar<this>; // <-- this is pushing a Bar on the stack
}
declare class Bar<T> extends Creatable<T> {
constructor(creatableStack: MyObectType[], name: string);
}
declare class Foo<T> extends Creatable<T> {
constructor(creatableStack: MyObectType[], name: string);
baz(name: string): Baz<this>; // <-- this is pushing a Baz on the stack
}
declare class Baz<T> extends Creatable<T> {
constructor(creatableStack: MyObectType[], name: string);
}
declare class XYZZY<T> extends Creatable<T> {
constructor(creatableStack: MyObectType[], name: string);
}
declare class MySDK<T = undefined> { // default to an empty stack
creatableStack: MyObectType[];
constructor();
foo(name: string): Foo<this>; // <-- this is pushing a Foo on the stack
xyzzy(name: string): XYZZY<this>; // <-- this is pushing a XYZZY
finalize(): MySDK; // <-- this essentially clears the stack
}
And we can test that it works as desired:
const sdk = new MySDK();
sdk // MySDK
.foo('foo1') // Foo<MySDK>
.done() // MySDK
.foo('foo2') // Foo<MySDK>
.bar('bar1') // Bar<Foo<MySDK>
.done() // Foo<MySDK>
.baz('baz1') // Baz<Foo<MySDK>>
.done() // Foo<MySDK>
.done() // MySDK
.xyzzy('xyzzy') // XYZZY<MySDK>
.bar('bar2') // Bar<XYZZY<MySDK>>
.done() // XYZZY<MySDK>
.done() // MySDK
.finalize(); // MySDK
Looks good. The stack seems to be in the correct state the whole way through.
Caveats here: I didn't bother keeping track of your original ObjectType
; I just redefined it to any
. It's possible you could make these properly generic in some way, but I don't know that it matters. If it does, it's probably out of scope for this question, which I interpret more about keeping track of state.
Also, your implementations are moving array references around and push()
ing and pop()
ping items onto and off of them. That has the potential to be a problem. The types are immutable but the values are not, so if someone writes
sdk.foo('abc');
sdk.foo('def');
now you have two Foo
s on the stack but that information is not properly encoded anywhere. You have to try to enforce that you only chain method calls and that any references are used at most once, so that once you call sdk.foo('abc')
, sdk
is off-limits. If you can't guarantee that, then you might want to make your values immutable also, by copying array contents instead of array references.