3

Using Angular 1.6 in combination with ES6-classes i ran into the following issue:

I wrote a service with some dependencies (surprise!)

class myService {

    /*@ngInject*/
    constructor($q){
      this.$q = $q;
      this.creationDate = new Date();
    }

    doStuff(data){
      return this.$q.when(data);
    }
}

angular.module('app').service('myService', myService)

However i got a build-target in which the service needed to be a bit fancier, so i extended it and used the extended service in that case instead:

class myFancyService extends myService{

    /*@ngInject*/
    constructor($q, $http){
      super($q);
      this.$http = $http;
    }

    doFancyStuff(data){
      console.log(this.creationDate);
      return this.doStuff(data)
          .then((stuff) => this.$http.post('api.myapp', stuff));
    }
}

angular.module('app').service('myService', myFancyService)

This works fine so far, but has a major drawback:

By calling super(dependencies), the dependencies of my base-class can't get injected automatically from @ngInject. Thus i need to be extremely aware that anytime i change the dependencies of myService, the dependencies of myFancyService (and any other potential future child-class) need to be changed as well.

I can not use Composition instead of Inheritance because myService is not registered as angular-service and thus can't be injected as dependency.

Question:

Is there a way to inject dependencies of the baseclass automatically anyways?

If not, is there at least a way to let my unittests remind me that i need to update the dependencies of myFancyService? I couldn't find a way yet to test with karma/jasmine if the arguments (or maybe just the number of arguments) of super($q) equal the (number of) arguments of the myService-constructor.

H W
  • 2,556
  • 3
  • 21
  • 45

2 Answers2

4

Two things to keep in mind:

  1. in Inheritance Pattern having interface consistency is essential, child classes can re-implement methods or properties but they cannot change how a method is invoked (arguments, etc...)
  2. You are still registering BaseService to the dependency injection but you might don't need for that, because it looks like an abstract class for you.

This could solve your problem (run script to see what's happening) You basically need to extend the static $inject property in each derived class and use destructuring in each child constructor:

  • Benefits: You don't need to know what's dependencies a parent class has.
  • Constrains: Always use first parameters in your child class (because rest operator must be the last)

function logger(LogService) {
  LogService.log('Hello World');
}

class BaseService {
  static get $inject() { 
    return ['$q']; 
  }

  constructor($q) {
    this.$q = $q;
  }
  
  log() {
    console.log('BaseService.$q: ', typeof this.$q, this.$q.name);
  }
}

class ExtendedService extends BaseService {
  static get $inject() { 
    return ['$http'].concat(BaseService.$inject); 
  }

  constructor($http, ...rest) {
    super(...rest);
    this.$http = $http;
  }
  
  log() {
    super.log();
    console.log('ExtendedService.$http: ', typeof this.$http, this.$http.name);
  }
}


class LogService extends ExtendedService {
  static get $inject() { 
    return ['$log', '$timeout'].concat(ExtendedService.$inject); 
  }

  constructor($log, $timeout, ...rest) {
    super(...rest);
    this.$log = $log;
    this.$timeout = $timeout;
  }
  
  log(what) {
    super.log();
    this.$timeout(() => {
      this.$log.log('LogService.log', what);
    }, 1000);
  }
}

angular
  .module('test', [])
  .service('BaseService', BaseService)
  .service('ExtendedService', ExtendedService)
  .service('LogService', LogService)
  .run(logger)
;
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.4/angular.js"></script>

<section ng-app="test"></section>

I have also opened a feature request in babel-plugin-angularjs-annotate: https://github.com/schmod/babel-plugin-angularjs-annotate/issues/28

Hitmands
  • 13,491
  • 4
  • 34
  • 69
  • 1
    I've used a `static getter` because [`properties initializers`](https://esdiscuss.org/topic/es7-property-initializers) are not standard yet (if you use babel or typescript, you can also use them). – Hitmands Aug 15 '17 at 11:30
  • 1
    This is great, thanks a lot! I am doing some more reading on `ng-annotate` and the `$inject`-property to see if i can somehow keep injecting the services automatically instead of typing out the string-array (and keeping it in sync with the parameter-list). But otherwise this is exactly what i was looking for! – H W Aug 15 '17 at 12:01
  • If it does the job, then, please accept it after your investigation. – Hitmands Aug 15 '17 at 12:02
  • 1
    I will be going with this solution (and might add something like this: https://stackoverflow.com/a/37330930/3967289 to help me bind the dependencies automatically) – H W Aug 15 '17 at 14:30
  • you could link it to this thread, it will be very helpful to next users. Great. – Hitmands Aug 15 '17 at 14:33
1

In code above super requires arguments to be specified explicitly.

A more failproof way is to do all dependency assignments in current class:

constructor($q, $http){
  super();
  this.$q = $q;
  this.$http = $http;
}

This can create problems if these services are used in parent constructor. It's not that easy to test arguments of parent constructor because this involves module mocks. A simple and relatively reliable way to test this is to assert:

expect(service.$q).toBe($q);
expect(service.$http).toBe($http);

This should be done in any Angular unit test, in fact, even if a class wasn't inherited.

A better way is to introduce base class that handles DI, considering that all that @ngInject does is creating $inject annotation:

class Base {
  constructor(...deps) {
    this.constructor.$inject.forEach((dep, i) => {
      this[dep] = deps[i];
    }
  }
}
BaseService.$inject = [];

class myService extends Base {
    /*@ngInject*/
    constructor($q){
      super(...arguments);
      ...
    }
    ...
}

At this point it becomes obvious that @ngInject doesn't really help anymore and requires to mess with arguments. Without @ngInject, it becomes:

class myService extends Base {
    static get $inject() {
      return ['$q'];
    }

    constructor(...deps){
      super(...deps);
      ...
    }
    ...
}

If dependency assignments are the only things that are done in child constructor, a constructor can be efficiently omitted:

class myService extends Base {
    static get $inject() {
      return ['$q'];
    }

    ...
}

It's even neater with class fields and Babel/TypeScript (no native support in browsers):

class myService extends Base {
    static $inject = ['$q'];

    ...
}
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • As far as i can see, this does not answer my question: Tests for the Child-class like `expect(service.$q).toBe($q);` will not remind me to update the child-classes dependencies, unless i already remembered to update the tests. Also your example implementation of the Baseclass can't be used as service without being inherited because it does not have dependencies of its own. (It's more an abstract base-class than a working service, that needs to be enhanced in some situations). In case I misunderstood, please elaborate on how this can be used to let the child manage only its own dependencies! – H W Aug 15 '17 at 09:33
  • I'm not sure what you mean. The purpose of code above is to avoid things like `super($q)` because they need to be maintained carefully. Yes, `Base` is abstract class, all service classes should inherit it, it allows to manage all deps in child class. I don't think that your problem with unit tests has a good solution, if you forget something to test, it won't be tested. It looks more like a use case for TypeScript, it's capable of detecting this sort of human errors. – Estus Flask Aug 15 '17 at 11:14