0

I want to make method chain class in typescript. (like a.add(3).add(3).mul(3),,,,)

But in my case, only part of methods are accessible after method according to the syntax guide

In example, after A method, A and B method is available. and after B method, B and C method is available.

I implement it like following and success. the type detect and linter are work very well, but I don't think it is the right way to implement. running code is in here

class ABC {
  private log: string[]
  public constructor() {
    this.log = [];

    this.A = this.A.bind(this);
    this.B = this.B.bind(this);
    this.C = this.C.bind(this);
    this.getLog = this.getLog.bind(this);
  }

  public A() {
    this.log.push('A');
    return { A: this.A, B: this.B, getLog: this.getLog };
  }

  public B() {
    this.log.push('B');
    return { B: this.B, C: this.C, getLog: this.getLog };
  }

  public C() {
    this.log.push('C');
    return { C: this.C, getLog: this.getLog };
  }

  public getLog() {
    return this.log;
  }
}

const abc = new ABC();
const log = abc.A().A().A().B().B().C().getLog();
console.log('log: ', log);

Cause I have to care about return type for every single method since there MIGHT be almost hundreds of methods in class

So what I want is manage all method syntaxes(method availability? list of method accessible after method?) in one single object, and generate class interface according to that syntax.

As you can see in below, I tried to make roughly what I want. But maybe because of recursion, this type do not worke correctly :( Is there some way to solve this? Or is there cool way to provide my requirement?

I think it will be best to have some type 'Chain' like

type a = type Chain

//is same as the type of class ABC that I implemented above

// information of function state machine
const Syntax = {
  A: {
    next: ['A', 'B'] as const,
  },
  B: {
    next: ['B', 'C'] as const,
  },
  C: {
    next: ['C'] as const,
  },
};

interface Chain {
  A(): Pick<Chain, typeof Syntax.A.next[number]>;
  B(): Pick<Chain, typeof Syntax.B.next[number]>;
  C(): Pick<Chain, typeof Syntax.C.next[number]>;
  getLog(): string;
}

class ChainABC implements Chain{
  ~~
}

Re-Attach play code url for class ABC running code is in here

2
  • I don't understand what you're trying to do. Write a function that generates a class with the correct type? Commented Jul 16, 2019 at 13:03
  • that will be perfect if possible :) want to generate class by using 'Syntax' information. Commented Jul 16, 2019 at 13:13

1 Answer 1

4

This is complicated enough that I don't even think I can explain it properly. First, I will change your base class to something that returns just this for the methods you intend to make chainable. At runtime this is essentially the same; it's only the compile-time type definitions that are wrong:

class ABC {
  private log: string[] = [];

  public A() {
    this.log.push("A");
    return this;
  }

  public B() {
    this.log.push("B");
    return this;
  }

  public C() {
    this.log.push("C");
    return this;
  }

  public getLog() {
    return this.log;
  }
}

Then I want to describe how to interpret the ABC constructor as a ChainABC constructor, since both are the same at runtime.

Let's come up with a way to determine the chainable methods of a class... I'll say they are just those function-valued properties which return a value of the same type as the class instance:

type ChainableMethods<C extends object> = {
  [K in keyof C]: C[K] extends (...args: any) => C ? K : never
}[keyof C];

And when you turn ABC into ChainABC you will need a type which maps such chainable methods to a union of other chainable methods, which meets this constraint:

type ChainMap<C extends object> = Record<
  ChainableMethods<C>,
  ChainableMethods<C>
>;

Finally we will describe ChainedClass<C, M, P> where C is the class type to modify, M is the method chaining map, and P is the particular keys we'd like to have exist on the result:

type ChainedClass<
  C extends object,
  M extends ChainMap<C>,
  P extends keyof C
> = {
  [K in P]: C[K] extends (...args: infer A) => C
    ? (
        ...args: A
      ) => ChainedClass<
        C,
        M,
        | Exclude<keyof C, ChainableMethods<C>>
        | (K extends keyof M ? M[K] : never)
      >
    : C[K]
};

This is recursive... and complicated. Basically ChainedClass<C, M, P> looks like Pick<C, P> but with the chainable methods in C replaced by methods that return ChainedClass<C, M, Q> where Q is the correct set of keys from C.

Then we make the function that turns the ABC constructor into the ChainABC constructor:

const constrainClass = <A extends any[], C extends object>(
  ctor: new (...a: A) => C
) => <M extends ChainMap<C>>() =>
  ctor as new (...a: A) => ChainedClass<C, M, keyof C>;

It's curried because we want to infer A and C but need to manually specify M.

Here's how we use it:

const ChainABC = constrainClass(ABC)<{ A: "A" | "B"; B: "B" | "C"; C: "C" }>();

See how the M type is {A: "A" | "B"; B: "B" | "C"; C: "C"}, representing the constraint you wanted to place, I think.

Testing it:

const o = new ChainABC()
  .A()
  .A()
  .A()
  .B()
  .B()
  .C()
  .C()
  .getLog();

That works. You will notice if you inspect the Intellisense that you can't call C() after A() and you can't call A() after B() and you can't call A() or B() after C().

All right, hope that helps; good luck!

Link to code

Sign up to request clarification or add additional context in comments.

2 Comments

thanks a lot! I'm not sure I understand the logic 100% correctly but it seems like exact solution that I want :) the basic logic is create a chained class with all possibility and exclude possibility by Syntax.. right? I think need some time to digestion your code haha I really appreciate for your help
@jcalz I found this beautifully overengineered answer looking to determine the return type of Array<Function>.reduce(). Would it be possible to do something similar to your answer such that const o = (['A', 'A', 'B', 'C'] as const).reduce((val, cur) => val[cur](), new ChainABC()); compiles? Here's a playground for what I'm attempting.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.