There are several valid justifications for this specific type error. Given your current usage of these features, your approach appears quite logical. However, when we expand on the example slightly, you may start to grasp why it falls apart.
Let's establish a new class called "FinalFinal", which extends "Final". It's important to note that this is completely permissible.
class FinalFinalChildClass extends FinalChildClass {
divide(divisor: number) {
return this.addTask((v) => v / divisor);
}
}
Our updated code looks like this:
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 approves without any issues (except for the noticeable error you've already pointed out), but during runtime our getDescendantClass()
fails to deliver a constructor that would build a new FinalFinalChildClass
as promised. Instead, it produces a FinalChildClass
. Thus, although TypeScript compiles successfully, we encounter the following runtime error:
Tasker.add(...).multiply(...).add(...).divide is not a function
This error message is accurate: "T was instantiated with a different subtype of constraint 'FinalChildClass'". As a result, there is now a disconnect between our TypeScript types and the actual prototype chain at runtime.
Try it out yourself.
We require a more robust approach that can construct new instances of Descendant classes successfully.
One solution is to abandon the flawed idea of determining the descendant type within the base class. This information is unknown and unknowable. Instead, we can rely on convention and utilize existing JavaScript APIs to retrieve the constructor for the current this
.
Our revised strategy eliminates the faulty getDescendantClass()
function in favor of using
Object.getPrototypeOf(this).constructor
to obtain the constructor. (Note that the returned type is
any
, so we need to provide explicit typing to specify the expected return type to the compiler.)
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 method provides strong typing during compilation:
And ensures error-free functionality at runtime:
While there remains a possibility that someone could create a class extending ParentClass
with an incompatible constructor implementation, no TypeScript feature exists to restrict how subclasses are designed. Some level of adherence to conventions and proper documentation will be necessary, but this practice isn't uncommon in our field.