Introduction
TL;DR? Read the implementation details here.
Note: This post was written for AngularJS 1.x. Angular 2+ has different conventions that make parts of this post obsolete.
TypeScript is a fantastic language that extends JavaScript by providing static typing syntax. Writing TypeScript to utilize AngularJS can be clunky at times, and one pain point for me was in writing directives.
AngularJS expects to be feed a factory function that returns an object that defines parameters and functionality for your directive.
In JavaScript, that looks like this.
module.directive('myDirective', function()
{
return {
scope: {},
template: '<div>{{name}}</div>',
link: function (scope)
{
scope.name = 'Aaron';
}
};
}
A direct translation of this to TypeScript looks like this. By the way, I am using the angular.d.ts type definition file from definitelytyped.org.
module MyModule.Directives
{
export interface IMyScope extends ng.IScope
{
name: string;
}
export function MyDirective(): ng.IDirective
{
return {
template: '<div>{{name}}</div>',
scope : {},
link : (scope: IMyScope) =>
{
scope.name = 'Aaron';
}
};
}
}
The registration is then handled as follows.
module.directive('projects', MyModule.Directives.MyDirective.Factory());
Not too different, really, but I feel this doesn't encapsulate a directive very well, nor does it really take advantage of the utility of TypeScript.
Why this is problematic
If MyDirective
is a base class, I would not be able to extend MyDirective
with a subclass. Granted, because TypeScript is a superset of JavaScript, it would be possible to extend this function through prototypal inheritance, or one of a plethora of such approaches. This, however, has the disadvantage of muddying your TypeScript with syntax and code that doesn't necessarily need to be there.
A real-world example without a class
To bring my examples into the real world, here's a previous iteration of a directive I wrote for my personal site. This uses the export function
syntax.
module AaronholmesNet.Directives
{
interface IProject extends Resources.IProject
{
title: string;
active: boolean;
}
interface IProjectsScope extends ng.IScope
{
[key: string] : any;
projects: Interfaces.IListInterface<IProject>
}
// return my repositories first, and forks second.
// from there, sort by last change time.
function ProjectSort(a: Resources.IProject, b: Resources.IProject): number
{
if (a.fork === false && b.fork === true) return -1;
if (a.fork === true && b.fork === false) return 1;
if (a.updated_at > b.updated_at) return -1;
if (a.updated_at < b.updated_at) return 1;
return 0;
}
export function ProjectsDirective(ProjectResource: Resources.IProjectResource, $location: ng.ILocationService, $sanitize: ng.sanitize.ISanitizeService, $sce: ng.ISCEService): ng.IDirective
{
return {
templateUrl: '/Views/Home/projects.html',
scope : {},
link : (scope: IProjectsScope) =>
{
var projectMap: { [key: number]: IProject; } = {};
scope.projects = [];
ProjectResource.query((data: IProject[]) =>
{
data.sort(ProjectSort);
var pathname = $location.path();
var activeSet = false;
data.forEach((project: IProject) =>
{
project.active = pathname == '/' + project.id;
activeSet = activeSet || project.active;
project.name = $sanitize(project.name);
project.description = $sanitize(project.description);
project.url = $sce.trustAsUrl(project.url);
project.readme = $sce.trustAsHtml(project.readme);
project.title = project.name + (project.fork ? ' (fork)' : ' (repo)');
scope.projects.push(project);
projectMap[project.id] = scope.projects[scope.projects.length - 1];
});
if (!activeSet)
{
data[0].active = true;
}
},
(data: any) =>
{
throw new Error(data);
});
// toggle which tab and tab detail is visible when a link is clicked
scope.$on('$locationChangeStart', (event, next, current) =>
{
var a = <HTMLAnchorElement>document.createElement('A');
a.href = current;
var pathname = (<string>(a.pathname.match(/^\/(\d+)/) || [,0]))[1];
var currentId = pathname == undefined ? 0 : parseInt(pathname, 10);
a.href = next;
pathname = (<string>(a.pathname.match(/^\/(\d+)/) || [,0]))[1];
var nextId = pathname == undefined ? 0 : parseInt(pathname, 10);
currentId && (projectMap[currentId].active = false);
nextId && (projectMap[nextId].active = true);
});
}
};
}
ProjectsDirective['$inject'] = ['ProjectResource', '$location', '$sanitize', '$sce'];
}
The issues with this approach
Because the exported function does not utilize a class structure, it's necessary to either use prototypal inheritance or methods exposed in the exported functions scope.
ProjectSort
is a function that would be better served as a private method in a class.The
link
method is much larger than it needs to be and could be slimmed down by moving the$locationChangeStart
andQuery
handler methods into the outer function scope. However, this becomes cumbersome to manage with many enclosed methods outside of thelink
function body and when you need to expose variables likescope
, and$location
. You then have to manage those variables in the outer scope as well.Due to the length of
ProjectsDirective
, the minification-safe list of dependencies (ProjectsDirective['$inject']
) is quite removed from the function signature. It would be easy to forget to include this, or to update it if your dependencies change.I've never been a huge fan of returning an object from a function to define my directive, and I would simply like to avoid it.
What can be done instead
Thankfully, it's possible to write directives as a class with a bit of a shift in thinking about how you would organize TypeScript, as opposed to how you would organize JavaScript.
The key point to keep in mind is that AngularJS still expects a function that returns an object. It turns out it's simple and clean to do this with a static Factory
method on your class.
That first example of a TypeScript directive looks like this as a class.
module MyModule.Directives
{
export interface IMyScope extends ng.IScope
{
name: string;
}
export class MyDirective
{
public link: (scope: IMyScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void;
public template = '<div>{{name}}</div>';
public scope = {};
constructor()
{
MyDirective.prototype.link = (scope: IMyScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) =>
{
scope.name = 'Aaron';
};
}
public static Factory()
{
var directive = () =>
{
return new MyDirective();
};
directive['$inject'] = [''];
return directive;
}
}
}
What this accomplishes
We now have proper properties, fields, and methods on our class instance.
link
,template
, andscope
are exposed in JavaScript as function properties. If I extend this class, my subclass can override these properties and still utilize the base class functionality.The link method is now another property on the class where its initialization can utilize the class instances scope for property access.
The factory function is very short, and so the list of dependencies is immediately in front of the developer. While not perfect, it makes it a little more obvious that the directive function and its
$inject
property are related.This completely avoids having to return an object from a function because your class instance is the object that
Factory
returns to angular.
A real-world example with a class
Now let's take a look at how I refactored my original ProjectsDirective
to utilize a class structure. You can see here how I take advantage of exposing public properties as the same properties the directive method would normally set in the object it returns. You can also see the private methods and properties I've made available to the class instance in order to avoid relying on function scoping.
module AaronholmesNet.Directives
{
'use strict';
export interface IProject extends Resources.IProject
{
title: string;
active: boolean;
}
export interface IProjectsScope extends ng.IScope
{
[key: string] : any;
projects: Interfaces.IListInterface<IProject>
}
export class ProjectsDirective
{
// #region Angular directive properties, fields, and methods
public templateUrl = '/Views/Home/projects.html';
public scope = {};
public link: (scope: IProjectsScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void;
// #endregion
// #region Initialization and destruction
constructor(ProjectResource: Resources.IProjectResource, $location: ng.ILocationService, $sanitize: ng.sanitize.ISanitizeService, $sce: ng.ISCEService)
{
this._$location = $location;
this._$sanitize = $sanitize;
this._$sce = $sce;
ProjectsDirective.prototype.link = (scope: IProjectsScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) =>
{
scope.projects = [];
ProjectResource.query(this._handleProjectQuerySuccess.bind(this), this._handleProjectQueryError.bind(this));
// toggle which tab and tab detail is visible when a link is clicked
scope.$on('$locationChangeStart', this._handleLocationChangeStart.bind(this));
scope.$on('$destroy', this.destruct);
this._scope = scope;
}
}
public static Factory()
{
var directive = (ProjectResource: Resources.IProjectResource, $location: ng.ILocationService, $sanitize: ng.sanitize.ISanitizeService, $sce: ng.ISCEService) =>
{
return new ProjectsDirective(ProjectResource, $location, $sanitize, $sce);
};
directive['$inject'] = ['ProjectResource', '$location', '$sanitize', '$sce'];
return directive;
}
private destruct()
{
this._projectMap = null;
this._$location = null;
this._$sanitize = null;
this._$sce = null;
this._scope = null;
}
// #endregion
// #region Private class properties, fields, and methods
private _projectMap : { [key: number]: IProject; } = {};
private _$location : ng.ILocationService;
private _$sanitize : ng.sanitize.ISanitizeService;
private _$sce : ng.ISCEService;
private _scope : IProjectsScope;
// #endregion
// #region Private event handlers
// return my repositories first, and forks second.
// from there, sort by last change time.
private _projectSort(a: Resources.IProject, b: Resources.IProject): number
{
if (a.fork === false && b.fork === true) return -1;
if (a.fork === true && b.fork === false) return 1;
if (a.updated_at > b.updated_at) return -1;
if (a.updated_at < b.updated_at) return 1;
return 0;
}
private _handleProjectQuerySuccess(data: IProject[]): void
{
data.sort(this._projectSort);
var pathname = this._$location.path();
var activeSet = false;
data.forEach((project: IProject) =>
{
project.active = pathname == '/' + project.id;
activeSet = activeSet || project.active;
project.name = this._$sanitize(project.name);
project.description = this._$sanitize(project.description);
project.url = this._$sce.trustAsUrl(project.url);
project.readme = this._$sce.trustAsHtml(project.readme);
project.title = project.name + (project.fork ? ' (fork)' : ' (repo)');
this._scope.projects.push(project);
this._projectMap[project.id] = this._scope.projects[this._scope.projects.length - 1];
});
if (!activeSet)
{
data[0].active = true;
}
}
private _handleProjectQueryError(data: any): void
{
throw new Error(data);
}
private _handleLocationChangeStart(event: ng.IAngularEvent, next: string, current: string): void
{
var a = <HTMLAnchorElement>document.createElement('A');
a.href = current;
var pathname = (<string[]>(a.pathname.match(/^\/(\d+)/) || [, 0]))[1];
var currentId = pathname == undefined ? 0 : parseInt(pathname, 10);
a.href = next;
pathname = (<string[]>(a.pathname.match(/^\/(\d+)/) || [, 0]))[1];
var nextId = pathname == undefined ? 0 : parseInt(pathname, 10);
currentId && (this._projectMap[currentId].active = false);
nextId && (this._projectMap[nextId].active = true);
}
// #endregion
}
}
Takeaways and wrap up
This approach is not perfect, however I feel it has real strength when focusing heavily on object-oriented programming. I don't demonstrate it here, but the ability to extend base class directives has been very helpful in another project. I also believe the encapsulation is much more clear, and lends itself to avoiding many of the issues we're all familiar with in regards to prototypal inheritance and JavaScript's strange function scoping rules.
Gotchas
It's important to note that you may have to bind contexts for event handlers. For example, with this call
scope.$on('$locationChangeStart', this._handleLocationChangeStart.bind(this));
we must bind_handleLocationChangeStart
to the class instance context becausescope.$on
will call it within the context ofwindow
. If someone knows of a way to handle this in TypeScript withoutbind
, I'd love to hear your input.The
scope
property is public, and is the same property that is returned from a directive function._scope
is private and is the actual directive's scope object, not the isolate scope definition.It sucks that many parts of the directive function signature are duplicated in the function returned from Factory, the instantiation call, and the constructor signature. I would love to hear alternate ways to accomplish this.
- b091 discovered a way to avoid both the redundancies and the Factory method by using decorators. See this comment for more information.
It is possible to unintentially create only a single instance of your directive by binding functions and variables in the constructor. For any data members that need to be unique between instances, ensure that they are added to the classes
prototype
rather than the instance itself. See this comment for more information.
Back to the basics
To summarize, here are the basic pieces you need to get this working.
- A static factory method and a constructor.
class MyDirective
{
constructor(/*list of dependencies*/)
{
}
public static Factory()
{
}
}
- A public
link
method that accepts the same parameters any AngularJS directive accepts, and returnsvoid
. Include any other directive properties you need, such astemplate
andscope
.
class MyDirective
{
public link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void;
public template = '<div>{{name}}</div>';
public scope = {};
constructor(/*list of dependencies*/)
{
}
public static Factory()
{
}
}
- The initialization of the link method.
class MyDirective
{
public link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void;
public template = '<div>{{name}}</div>';
public scope = {};
constructor(/*list of dependencies*/)
{
// It's important to add `link` to the prototype or you will end up with state issues.
// See http://blog.aaronholmes.net/writing-angularjs-directives-as-typescript-classes/#comment-2111298002 for more information.
MyDirective.prototype.link = (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) =>
{
/*handle all your linking requirements here*/
};
}
public static Factory()
{
}
}
- The instantiation call from your Factory method.
class MyDirective
{
public link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void;
public template = '<div>{{name}}</div>';
public scope = {};
constructor(/*list of dependencies*/)
{
// It's important to add `link` to the prototype or you will end up with state issues.
// See http://blog.aaronholmes.net/writing-angularjs-directives-as-typescript-classes/#comment-2111298002 for more information.
MyDirective.prototype.link = (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) =>
{
/*handle all your linking requirements here*/
};
}
public static Factory()
{
var directive = (/*list of dependencies*/) =>
{
return new MyDirective(/*list of dependencies*/);
};
directive['$inject'] = ['/*list of dependencies*/'];
return directive;
}
}
- And finally, the registration of your directive with AngularJS by calling the Factory method.
It's important to note that Factory
is executed here, and its returned value (the directive) is passed to Angular's registration function. Be sure to include the parenthesis!
angular.module('MyModule', [])
.directive('myDirective', MyDirective.Factory());