There are some good reasons for that type error. For the way you happen to be using these features, your approach looks very reasonable. However, if we stretch the example a bit, I think you'll begin to see why it breaks down.
Let's create a new class named "FinalFinal" that extends "Final". Note that this is perfectly legal.
class FinalFinalChildClass extends FinalChildClass {
divide(divisor: number) {
return this.addTask((v) => v / divisor);
}
}
Our new code will now be:
const Tasker = new FinalFinalChildClass();
// ✔️ No TS errors (2) - I'm able to chain functions from all children
const task = Tasker.add(2).multiply(2).add(2).divide(5);
const result = task.exec(2);
console.log(result); // 2
TypeScript is perfectly happy (except for the pesky error you've already noted), but at runtime our getDescendantClass() does not return a constructor which will build a new FinalFinalChildClass as promised. Instead, it will build a FinalChildClass. So even though TypeScript will compile, we will get the following runtime error:
Tasker.add(...).multiply(...).add(...).divide is not a function
This is because the error message was exactly correct: "T was instantiated with a different subtype of constraint 'FinalChildClass'" and now our TypeScript types and the actual runtime prototype chain are out of whack.
Try it for yourself.
Instead, we need a more resilient approach which is able to build new instances of Descendant classes.
One approach is to abandon the misguided attempt to divine the descendant type from within the base class. We don't know, and can't know. Instead, we can lean on convention and use existing JavaScript APIs to get the constructor for the current this.
Our new approach has discarded the broken getDescendantClass() function and instead uses Object.getPrototypeOf(this).constructor to get the constructor. (Note that the returned type is any, so we need to use a little explicit typing to clue the compiler into the expected return type.)
abstract class ParentClass {
constructor(protected tasks: Task[] = []) {}
protected addTask(task: Task) {
const ctor:new (tasks: Task[]) => this = Object.getPrototypeOf(this).constructor;
return new ctor([...this.tasks, task]);
}
exec(value: number) {
for (const task of this.tasks) {
value = task(value);
}
return value;
}
}
abstract class ChildClass extends ParentClass {
add(toAdd: number) {
return this.addTask((v) => v + toAdd);
}
}
class FinalChildClass extends ChildClass {
multiply(factor: number) {
return this.addTask((v) => v * factor);
}
}
class FinalFinalChildClass extends FinalChildClass {
divide(divisor: number) {
return this.addTask((v) => v / divisor);
}
}
const Tasker = new FinalFinalChildClass();
// ✔️ No TS errors (2) - I'm able to chain functions from all children
const task = Tasker.add(2).multiply(2).add(2).divide(5);
const result = task.exec(2);
console.log(result); // 2
This approach both gives us good strong typing at compile time:
And error free code at runtime:
Of course, there is still a risk that someone could come along and implement a class that extends ParentClass but implements an incompatible constructor. Unfortunately, I am not aware of any TypeScript feature which allows a class author to restrict the way in which subclasses are implemented. A certain degree of convention and documentation will be required, but that doesn't seem too unusual in our field.