I have a base class Collection
which provides basic array features. This class gets extended for other domain-specific use cases. When a "destructing" method like filter
is called, it should return a new instance with the filtered elements (to continue making use of class methods instead of just getting the array back).
In PHP you'd use return new self()
to return the actual child or parent class, based on what it was constructed on (I think for Java it's return obj.newInstance()
). But with JS/TS I really struggle to find a solution to this. My current workaround is to overwrite the newInstance
method by all child classes.
Is there a solution to this?
class Collection<E> {
protected items: E[];
constructor(items: any[] = []) {
this.items = items;
}
// doesn't work, just one of many attempts
protected newInstance(items: E[]) {
return new Collection(items);
//return new this.constructor(items); // "This expression is not constructable"
// return new this.constructor.prototype(items); // another attempt, says "this.constructor.prototype is not a constructor"
}
size() {
return this.items.length;
}
// should filter and return new instance to use class methods
filter(callback: (item: any, index?: number) => boolean): this {
// should be the actual instance (can be inherited)
return this.newInstance(this.items.filter(callback)) as this;
}
}
class NumberCollection extends Collection<number> {
sum() {
return this.items.reduce((a, b) => a b, 0);
}
}
let numbers = new NumberCollection([1, 2, 3, 4]);
console.log(numbers.sum()); // works,
// throws "sum() is not a function"
console.log(numbers.filter((n) => n > 1).sum());
CodePudding user response:
Sadly, this is one of those things that's easy in JavaScript and incredibly awkward in TypeScript.
In JavaScript, you'd do one of two things:
new this.constructor(/*....*/)
as you mentioned.The species pattern.
Unfortunately, though, you can't do that without type assertions or outright @ts-ignore
s in TypeScript. See this related question about the species pattern, to which the answer is: you can't do that.
I think your only realistic option, if filter
(and such) always return an instance of the class they're called on (so it's #1 above, not #2), is to do what you've done with this
as the return type annotation, using new this.constructor(/*...*/)
with a @ts-ignore
on it:
protected newInstance(items: E[]): this {
// @ts-ignore - blech
return new this.constructor(items);
}
That correctly creates an instance of the class it's called on (even though TypeScript doesn't see the construction signature on this.constructor
) because by default the prototype assigned to a class instance has a constructor
property that refers to the constructor function: this.constructor
in an instance created via new Collection
is Collection
; this.constructor
in an instnace created via new NumberCollection
is NumberCollection
. (There are things you can do to mess that up, but class
syntax makes it easy to avoid most of them.) So new this.constructor(/*...*/)
creates a new object using (in the normal case) the constructor that was used to create this
. As a result, you don't have that sum
problem anymore; this works:
let numbers = new NumberCollection([1, 2, 3, 4]);
console.log(numbers.sum());
const x = numbers.filter((n) => n > 1);
// ^? −− type is NumberCollection
console.log(x.sum()); // works
I don't like it, but as far as I know, we're stuck with it for now.