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 Operable
implementation to only deal with "itself".
You found the polymorphic this
type, which is a form of recursive constraint, and is quite close to what you want; callers would indeed be restricted so that money.add(time)
and time.add(money)
would fail (unless time
and money
happen to be structurally compatible, which they're not). But implementations are similarly restricted, and this
doesn't mean "the class on which this method is declared", it means "the type of the current this
context, including possible subclasses." So the reason this fails:
class BadTime {
constructor(private readonly duration: number) { }
add(value: this): this {
return new BadTime(this.duration + value.duration); // error
}
}
is because the compiler cannot prevent you from doing this:
class ReallyBadTime extends BadTime {
hello() { console.log("hi") };
}
const rbt = new ReallyBadTime(10).add(new ReallyBadTime(20));
// ^? const rbt: ReallyBadTime
rbt.hello(); // RUNTIME ERROR
Inside ReallyBadTime
, this
is required to have a hello()
method, and so the add()
method must return something with a hello()
method. But the implementation of add()
inherited from BadTime
doesn't do this. The runtime error at rbt.hello()
is what the compiler error inside the add()
implementation is warning you about.
Anyway, if you want to be able to implement add()
and divide()
so that subclasses don't pretend to do things they can't do, you'll need to change this
to a more general recursive constraint. Here's one way to do it:
interface Operable<T extends Operable<T>> {
add(value: T): T;
divide(value: number): T;
}
The constraint T extends Operable<T>
expresses the "itself" restriction. That part is essentially the same as how the polymorphic this
type works under the covers. The difference is that the type argument for this
automatically narrows to whatever the calling object type is (e.g., ReallyBadTime
), while Operable<Time>
will always have Time
as T
even in subclasses.
Then you just have to propagate the constraint to Average
:
class Average<T extends Operable<T>> {
// ✂ snip ⋯ snip ✂ //
}
and you're pretty much done. If you want, you can use an implements
clause on Money
and Time
to make sure you've implemented it correctly:
class Money implements Operable<Money> {
// ✂ snip ⋯ snip ✂ //
}
class Time implements Operable<Time> {
// ✂ snip ⋯ snip ✂ //
}
but this is completely optional. An implements
clause 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 implemented Money
or Time
as an argument to new Average()
):
class Money {
// ✂ snip ⋯ snip ✂ //
}
class Time {
// ✂ snip ⋯ snip ✂ //
}
Okay, let's make sure it works:
const moneyAverage = new Average(new Money(10, "USD"));
moneyAverage.add(new Money(20, "GBP"));
moneyAverage.add(new Money(30, "EUR"));
const m = moneyAverage.getAverage();
console.log(m.toString()); // 30.00 USD // hmm, might not be right
const timeAverage = new Average(new Time(10));
timeAverage.add(new Time(20));
timeAverage.add(new Time(30));
const t = timeAverage.getAverage();
console.log(t.toString()) // 30 shakes of a lamb's tail // also not sure
timeAverage.add(new Money(40, "USD")); // error
// -----------> ~~~~~~~~~~~~~~~~~~~~
// Argument of type 'Money' is not assignable to parameter of type 'Time'.
const foo = { add() { return 1 }, divide() { return 2 } };
new Average(foo); // error!
// -------> ~~~
// Argument of type '{ add(): number; divide(): number; }' is not assignable to
// parameter of type 'Operable<{ add(): number; divide(): number; }>'.
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 time
and money
, or when you call new Average()
on something that isn't a valid Operable
, you get informative errors about them.