Home > Software design >  Sync method chaining in Typescript w/o promises/generics to return caller?
Sync method chaining in Typescript w/o promises/generics to return caller?

Time:10-29

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 Foos 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.

Playground link to code

  • Related