'Dynamic chainable methods on child classes

I'm trying to create a class that has chainable methods, which are dynamically attached to the prototype of the class, and I'm having problems extending such a class.

When extending the parent class, those chainable methods return the Parent class type, but I need them to correctly return the Child type.

const methods = ['foo', 'bar', 'baz'] as const

type Methods = typeof methods[number]

type ClassMethods<T> = {
  [K in Methods]: (data: T) => Parent<T>
}

export interface Parent<T> extends ClassMethods<T> {
  someMethod(data: T): this
}

export class Parent<T> implements ClassMethods<T> {
  someMethod(_data: T): this {
    return this
  }
}

export class Child<T> extends Parent<T> {
  childMethod(): this {
    return this
  }
}

new Child<string>().foo('foo').childMethod() //error in Typescript

new Child<string>().someMethod('foo').childMethod() //this is okay

The problem as I see it is that I can't use this in types and I can't map over types in interfaces but I need both of those things.

I'm open to any suggestions, even to completely re-architecture my approach if that's needed.

The only requirement is that the methods are chainable, and to be directly on the child.

TS Playground



Solution 1:[1]

This hacky workaround works fine but results in types that don't make sense and are long/harder to read.

What we really need is some way to change the return type of this:

type ClassMethods<T> = {
  [K in Methods]: (data: T) => Parent<T>
};

Currently as it stands, it always returns Parent<T> regardless of whether or not it was a child class. So ideally it should look something like this:

type ClassMethods<T, ChildClass> = {
  [K in Methods]: (data: T) => if is ChildClass then ChildClass else Parent<T>
};

I ended up using never for denoting the absence of a child class, and so we end up with:

type ClassMethods<T, ChildClass> = {
  [K in Methods]: (data: T) => [ChildClass] extends [never] ? Parent<T> : ChildClass;
};

We have to use [ChildClass] extends [never] so it properly checks if ChildClass is never. See for yourself; remove the tuple and suddenly it is not able to tell whether or not it is never. I don't really know why this is the case and hopefully someone can explain below.

Now because we have added a new generic parameter to this type, we must update Parent accordingly:

export interface Parent<T, ChildClass = never> extends ClassMethods<T, ChildClass> {

    someMethod(data:T): [ChildClass] extends [never] ? Parent<T> : ChildClass // this works Ok in child classes
}

export class Parent<T, ChildClass = never> implements ClassMethods<T, ChildClass> {
  //more chainable methods
  someMethod(_data: T): [ChildClass] extends [never] ? Parent<T> : ChildClass {
    return this as any; // questionable but the shortest way to ignore the error
  }
}

Again, using [ChildClass] extends [never] to check if there is a child class. This was not present in my original playground link in the comments and I have fixed it in this answer.

And finally because Parent can expect a child class:

export class Child<T> extends Parent<T, Child<T>> {
    childMethod():this{
        return this
    }
}

However one caveat is that the children must provide the second generic to Parent or else it will not function properly. This isn't great if the parent class is part of the public API.

But this does work! We now have successfully passed down the type of the child class to the class methods created by the mapped type.

Playground

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 hittingonme