简体   繁体   中英

How to declare recursive types in typescript for method chaining?

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

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"} {A: "A" | "B"; B: "B" | "C"; C: "C"} {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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM