45

Recently I started refactoring one of the Angular projects I am working on with TypeScript. Using TypeScript classes to define controllers is very convenient and works well with minified JavaScript files thanks to static $inject Array<string> property. And you get pretty clean code without splitting Angular dependencies from the class definition:

 module app {
  'use strict';
  export class AppCtrl {
    static $inject: Array < string > = ['$scope'];
    constructor(private $scope) {
      ...
    }
  }

  angular.module('myApp', [])
    .controller('AppCtrl', AppCtrl);
}

Right now I am searching for solution to handle similar case for the directive definition. I found a good practice to define the directives as function:

module directives {

  export function myDirective(toaster): ng.IDirective {
    return {
      restrict: 'A',
      require: ['ngModel'],
      templateUrl: 'myDirective.html',
      replace: true,
      link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ctrls) => 
        //use of $location service
        ...
      }
    };
  }


  angular.module('directives', [])
    .directive('myDirective', ['toaster', myDirective]);
}

In this case I am forced to define Angular dependencies in the directive definition, which can be very error-prone if the definition and TypeScript class are in different files. What is the best way to define directive with typescript and the $inject mechanism, I was searching for a good way to implement TypeScript IDirectiveFactory interface but I was not satisfied by the solutions I found.

tanguy_k
  • 8,999
  • 4
  • 46
  • 50
Milko Lorinkov
  • 553
  • 1
  • 5
  • 7

9 Answers9

114

Using classes and inherit from ng.IDirective is the way to go with TypeScript:

class MyDirective implements ng.IDirective {
    restrict = 'A';
    require = 'ngModel';
    templateUrl = 'myDirective.html';
    replace = true;

    constructor(private $location: ng.ILocationService, private toaster: ToasterService) {
    }

    link = (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ctrl: any) => {
        console.log(this.$location);
        console.log(this.toaster);
    }

    static factory(): ng.IDirectiveFactory {
        const directive = ($location: ng.ILocationService, toaster: ToasterService) => new MyDirective($location, toaster);
        directive.$inject = ['$location', 'toaster'];
        return directive;
    }
}

app.directive('mydirective', MyDirective.factory());

Related answer: https://stackoverflow.com/a/29223360/990356

Community
  • 1
  • 1
tanguy_k
  • 8,999
  • 4
  • 46
  • 50
  • 3
    Excellent work! By far the cleanest approach I've seen! – Mobiletainment Oct 02 '15 at 12:39
  • Nice workaround! However, to avoid the injection wrapping you could also use simple controller injection as in the answer provided by @Mobiletainment http://stackoverflow.com/a/32934956/40853 – mattanja Nov 05 '15 at 07:45
  • 2
    Can we achive this without using link function ? I am using Angular 1.4 and since we will be proting our code to Angular 2.0 and link functions are not supported there, i dont want to write this logic using link function.. So please let me know if it is possible to access the element without the link function. – ATHER May 25 '16 at 22:38
  • You can skip the `directive.$inject = ['$location', 'toaster'];` step by simply adding `'ngInject';` in the constructor function. – kayasky Jan 16 '17 at 19:35
34

I prefer to specify a controller for the directive and solely inject the dependencies there.

With the controller and its interface in place, I strongly type the 4th parameter of the link function to my controller's interface and enjoy utilizing it from there.

Shifting the dependency concern from the link part to the directive's controller allows me to benefit from TypeScript for the controller while I can keep my directive definition function short and simple (unlike the directive class approach which requires specifying and implementing a static factory method for the directive):

module app {
"use strict";

interface IMyDirectiveController {
    // specify exposed controller methods and properties here
    getUrl(): string;
}

class MyDirectiveController implements IMyDirectiveController {

    static $inject = ['$location', 'toaster'];
    constructor(private $location: ng.ILocationService, private toaster: ToasterService) {
        // $location and toaster are now properties of the controller
    }

    getUrl(): string {
        return this.$location.url(); // utilize $location to retrieve the URL
    }
}

function myDirective(): ng.IDirective {
    return {
        restrict: 'A',
        require: 'ngModel',
        templateUrl: 'myDirective.html',
        replace: true,

        controller: MyDirectiveController,
        controllerAs: 'vm',

        link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attributes: ng.IAttributes, controller: IMyDirectiveController): void => {
            let url = controller.getUrl();
            element.text('Current URL: ' + url);
        }
    };
}

angular.module('myApp').
    directive('myDirective', myDirective);
}
Mobiletainment
  • 18,477
  • 9
  • 70
  • 90
  • 6
    IMHO this is the best answer and this is how I'll be doing it as well since this does not require any special handling, no workarounds etc. Just plain default controller injection. – mattanja Nov 05 '15 at 07:43
  • Does the controller have to be registered with a module? – Blake Mumford Jan 29 '16 at 06:11
  • @BlakeMumford no. The directive's controller is just a regular class in that case. The only thing that needs to be registered with Angular is the directive itself – Mobiletainment Jan 29 '16 at 09:46
  • 1
    does anyone know why my controller has an undefined method getUrl when using it in the directive? I used the exact code with one minor change: angular.module('mezurioApp').directive('myDirective',[myDirective]); (use of array as second argument as it won't compile otherwise). – emp Apr 05 '16 at 14:14
  • Doesn't the require: 'ngModel' force the controller passed in to the link function to be the NgModelController and not the MyDirectiveController which you defined? – RMD Apr 15 '16 at 16:41
  • if I want to add attributes to the directive here say "scope: { test: '=test' }" and use this attribute in the controller. how do I do it ?. – Hrishikesh Sardar Feb 15 '17 at 17:04
9

In this case I am forced to define angular dependencies in the directive definition, which can be very error-prone if the definition and typescript class are in different files

Solution:

 export function myDirective(toaster): ng.IDirective {
    return {
      restrict: 'A',
      require: ['ngModel'],
      templateUrl: 'myDirective.html',
      replace: true,
      link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ctrls) => 
        //use of $location service
        ...
      }
    };
  }
  myDirective.$inject = ['toaster']; // THIS LINE
basarat
  • 207,493
  • 46
  • 386
  • 462
  • Thanks, but this still doesn't look quite well. I prefer to have one block which encapsulates the whole logic inside it. – Milko Lorinkov Nov 14 '14 at 08:49
  • 1
    This was what worked for me. Using a class for the directive as others have suggested didn't work, because I didn't have access to "this" inside the link function. – Sammi Jul 07 '15 at 16:43
4

It's a bit late to this party. But here is the solution I prefer to use. I personally think this is cleaner.

Define a helper class first, and you can use it anywhere.(It actually can use on anything if you change the helper function a bit. You can use it for config run etc. )

module Helper{
    "use strict";

    export class DirectiveFactory {
        static GetFactoryFor<T extends ng.IDirective>(classType: Function): ng.IDirectiveFactory {
            var factory = (...args): T => {
                var directive = <any> classType;
                //return new directive(...args); //Typescript 1.6
                return new (directive.bind(directive, ...args));
            }
            factory.$inject = classType.$inject;
            return factory;
        }
    }
}

Here is you main module

module MainAppModule {
    "use strict";

angular.module("App", ["Dependency"])
       .directive(MyDirective.Name, Helper.DirectiveFactory.GetFactoryFor<MyDirective>(MyDirective));

    //I would put the following part in its own file.
    interface IDirectiveScope extends ng.IScope {
    }

    export class MyDirective implements ng.IDirective {

        public restrict = "A";
        public controllerAs = "vm";
        public bindToController = true;    
        public scope = {
            isoVal: "="
        };

        static Name = "myDirective";
        static $inject = ["dependency"];

        constructor(private dependency:any) { }

        controller = () => {
        };

        link = (scope: IDirectiveScope, iElem: ng.IAugmentedJQuery, iAttrs: ng.IAttributes): void => {

        };
    }
}
maxisam
  • 20,632
  • 9
  • 67
  • 77
  • This requires compiling to ES6. new directive(...args); (the alt version does the same thing). w/o es6, it puts the dependencies in the first constructor param as an array. Do you know a solution that works for ES5? – Robert Baker Aug 14 '15 at 08:32
  • tried this didn't work var toArray = function(arr) { return Array.isArray(arr) ? arr : [].slice.call(arr); }; return new (directive.bind(directive, toArray(args))); – Robert Baker Aug 14 '15 at 08:45
  • 1
    I am sure it works. You need to have latest Typescript. Typescript will transpile it into ES5. – maxisam Aug 14 '15 at 14:11
  • I had to update my ts to today (I was on 20150807). Visual Studio code still displays the error, but it does work. //return new directive(...args); works – Robert Baker Aug 14 '15 at 21:03
  • weird. Mine was Typescript 1.5.3 tho, the version comes with VS2015. I didn't try it on vs code. Anyway, glad you got it work. – maxisam Aug 14 '15 at 21:27
3

This article pretty much covers it and the answer from tanguy_k is pretty much verbatim the example given in the article. It also has all the motivation of WHY you would want to write the class this way. Inheritance, type checking and other good things...

http://blog.aaronholmes.net/writing-angularjs-directives-as-typescript-classes/

Louis Duran
  • 115
  • 1
  • 9
3

Here is my solution:

Directive:

import {directive} from '../../decorators/directive';

@directive('$location', '$rootScope')
export class StoryBoxDirective implements ng.IDirective {

  public templateUrl:string = 'src/module/story/view/story-box.html';
  public restrict:string = 'EA';
  public scope:Object = {
    story: '='
  };

  public link:Function = (scope:ng.IScope, element:ng.IAugmentedJQuery, attrs:ng.IAttributes):void => {
    // console.info(scope, element, attrs, this.$location);
    scope.$watch('test', () => {
      return null;
    });
  };

  constructor(private $location:ng.ILocationService, private $rootScope:ng.IScope) {
    // console.log('Dependency injection', $location, $rootScope);
  }

}

Module (registers directive...):

import {App} from '../../App';
import {StoryBoxDirective} from './../story/StoryBoxDirective';
import {StoryService} from './../story/StoryService';

const module:ng.IModule = App.module('app.story', []);

module.service('storyService', StoryService);
module.directive('storyBox', <any>StoryBoxDirective);

Decorator (adds inject and produce directive object):

export function directive(...values:string[]):any {
  return (target:Function) => {
    const directive:Function = (...args:any[]):Object => {
      return ((classConstructor:Function, args:any[], ctor:any):Object => {
        ctor.prototype = classConstructor.prototype;
        const child:Object = new ctor;
        const result:Object = classConstructor.apply(child, args);
        return typeof result === 'object' ? result : child;
      })(target, args, () => {
        return null;
      });
    };
    directive.$inject = values;
    return directive;
  };
}

I thinking about moving module.directive(...), module.service(...) to classes files e.g. StoryBoxDirective.ts but didn't make decision and refactor yet ;)

You can check full working example here: https://github.com/b091/ts-skeleton

Directive is here: https://github.com/b091/ts-skeleton/blob/master/src/module/story/StoryBoxDirective.ts

b091
  • 449
  • 2
  • 7
  • best OO and TS solution. Have you considered whether you have an alternative to depending $rootScope? e.g. linking to only the injected scope objects from a directive controller? – OzBob Nov 23 '15 at 08:14
2

This answer was somewhat based off @Mobiletainment's answer. I only include it because I tried to make it a little more readable and understandable for beginners.

module someModule { 

    function setup() { 
        //usage: <some-directive></some-directive>
        angular.module('someApp').directive("someDirective", someDirective); 
    };
    function someDirective(): ng.IDirective{

        var someDirective = {
            restrict: 'E',
            templateUrl: '/somehtml.html',
            controller: SomeDirectiveController,
            controllerAs: 'vm',
            scope: {},
            link: SomeDirectiveLink,
        };

        return someDirective;
    };
    class SomeDirectiveController{

        static $inject = ['$scope'];

        constructor($scope) {

            var dbugThis = true;
            if(dbugThis){console.log("%ccalled SomeDirectiveController()","color:orange");}
        };
    };
    class SomeDirectiveLink{
        constructor(scope: ng.IScope, element: ng.IAugmentedJQuery, attributes: ng.IAttributes, controller){
            var dbugThis = true;
            if(dbugThis){console.log("%ccalled SomeDirectiveLink()","color:orange");}
        }
    };
    setup();
}
bbuie
  • 557
  • 5
  • 15
1

Another solution is to create a class, specify static $inject property and detect if the class is being called with the new operator. If not, call new operator and create an instance of the directive class.

here is an example:

module my {

  export class myDirective {
    public restrict = 'A';
    public require = ['ngModel'];
    public templateUrl = 'myDirective.html';
    public replace = true;
    public static $inject = ['toaster'];
    constructor(toaster) {
      //detect if new operator was used:
      if (!(this instanceof myDirective)) {
        //create new instance of myDirective class:
        return new (myDirective.bind.apply(myDirective, Array.prototype.concat.apply([null], arguments)));
      }
    }
    public link(scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ctrls:any) {

    }
  }

}
Szymon Wygnański
  • 9,426
  • 6
  • 29
  • 44
0

All options in answers gave me an idea that 2 entities(ng.IDirective and Controller) are too much to describe a component. So I've created a simple wrapper prototype which allows to merge them. Here is a gist with the prototype https://gist.github.com/b1ff/4621c20e5ea705a0f788.

Evgeniy
  • 108
  • 5