I have a code where I'm doing some statistics over some variables. It basically is the same code but duplicated over and over so I've decided to provide a nice abstraction.
The problem is that those statistics are performed over value objects so I need to define an interface for those objects to allow for the abstraction.
My 2 current value objects are Money and Time and I want to be able to plug them into a class like the following (the abstracted code):
class Average<T extends Operable> {
private value: T;
private count: number;
public constructor(init: T) {
this.value = init;
this.count = 0;
}
public add(value: T): void {
this.value = this.value.add(value);
this.count++;
}
public getAverage(): T {
return this.value.divide(Math.max(1, this.count));
}
}
For this, I need, of course, the Operable interface that Money and Time need to extend from so I went ahead and attempted to create it:
interface Operable {
add(value: Operable): Operable;
divide(value: number): Operable;
}
This, however, is not quite what I want because it would allow for Money and Time to be cross-operated (f.e. const m = new Money(); const t = new Time(); m.add(t);). In addition to this, my IDE rightfully complaints about this on Average.add method:
Type 'Operable' is not assignable to type 'T'.
'Operable' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Operable'.
A few Google searches later I learnt that you can use this in an interface to refer to the instance type so I changed the interface as follows:
interface Operable {
add(value: this): this;
divide(value: number): this;
}
This clears the error in Average.add but pushes it to the value object's methods (here is an example for Money.add):
Property 'add' in type 'Money' is not assignable to the same property in base type 'Operable'.
Type '(money: Money) => Money' is not assignable to type '(value: this) => this'.
Type 'Money' is not assignable to type 'this'.
'Money' is assignable to the constraint of type 'this', but 'this' could be instantiated with a different subtype of constraint 'Money'.
Is there a way to get this sort of construction working in Typescript? Many thanks!
Edit: please, note that my value objects are immutable so changing the Average.add method to just this.value.add(value); is not an option.
EDit2: Here are the definitions for Money and Time:
class Money {
private readonly amount: number;
private readonly currency: string;
public constructor(amount: number, currency: string) {
this.amount = amount;
this.currency = currency;
}
public add(value: Money): Money {
return new Money(this.amount + value.amount, this.currency);
}
public divide(value: number): Money {
return new Money(this.amount / value, this.currency);
}
}
class Time {
private readonly duration: number;
public constructor(duration: number) {
this.duration = duration;
}
public add(value: Time): Time {
return new Time(this.duration + value.duration);
}
public divide(value: number): Time {
return new Time(this.duration / value);
}
}
You're looking for so-called F-bounded quantification, also known as recursive constraints. You want an
Operableimplementation to only deal with "itself".You found the polymorphic
thistype, which is a form of recursive constraint, and is quite close to what you want; callers would indeed be restricted so thatmoney.add(time)andtime.add(money)would fail (unlesstimeandmoneyhappen to be structurally compatible, which they're not). But implementations are similarly restricted, andthisdoesn't mean "the class on which this method is declared", it means "the type of the currentthiscontext, including possible subclasses." So the reason this fails:is because the compiler cannot prevent you from doing this:
Inside
ReallyBadTime,thisis required to have ahello()method, and so theadd()method must return something with ahello()method. But the implementation ofadd()inherited fromBadTimedoesn't do this. The runtime error atrbt.hello()is what the compiler error inside theadd()implementation is warning you about.Anyway, if you want to be able to implement
add()anddivide()so that subclasses don't pretend to do things they can't do, you'll need to changethisto a more general recursive constraint. Here's one way to do it:The constraint
T extends Operable<T>expresses the "itself" restriction. That part is essentially the same as how the polymorphicthistype works under the covers. The difference is that the type argument forthisautomatically narrows to whatever the calling object type is (e.g.,ReallyBadTime), whileOperable<Time>will always haveTimeasTeven in subclasses.Then you just have to propagate the constraint to
Average:and you're pretty much done. If you want, you can use an
implementsclause onMoneyandTimeto make sure you've implemented it correctly:but this is completely optional. An
implementsclause doesn't affect the types; all it does is warn you inside the class if you've made a mistake. You can get by just fine without it (but any mistake you make will only be noticed when you try to use an improperly implementedMoneyorTimeas an argument tonew Average()):Okay, let's make sure it works:
Looks good. Aside from the weirdness of currencies and time units, code that's supposed to compile does compiler. And when you make mistakes, like mixing
timeandmoney, or when you callnew Average()on something that isn't a validOperable, you get informative errors about them.Playground link to code