Search code examples
typescriptinterfacethisimplementation

Typescript interface operating over the same implementation exclusively


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);
   }
}

Solution

  • 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.

    Playground link to code