AngularJS 1.x with TypeScript (or ES6) Best Practices
After working with AngularJS with TypeScript for the last couple of years there are a few best practices that seem to be missing from most tutorials.
These practices apply whether you are developing in plain old JavaScript (ES5), ES6 (ES2015) or TypeScript.
Code should never be future proofed, instead it should be extendable. Following these practices should help you create code that will easily be upgraded to Angular2 -- as well as be more maintainable.
Use External Modules
Using external modules will do two things for you.
- When bundling your application external module use will automatically order your dependencies for you.
- External modules will eliminate the need to use
///<reference path="..." />
notation.
Example:
// app.ts
import {module} from 'angular';
export let app = module('app', [
require('angular-ui-router'),
require('angular-animate'),
require('angular-ui-bootstrap'),
require('angular-translate')
]);
// PersonComponent.ts
import {app} from './app';
export class PersonComponent {
// . . .
}
app.component('PersonComponent', PersonComponent);
Don't Use TypeScript Internal Modules/Namespaces
Before the common use of external modules it was commonplace to use TypeScript internal modules, now renamed namespaces.
TypeScript namespaces are based on the JavaScript Internal Module pattern. This pattern came about because of the lack of module encapsulation in JavaScript. With the introduction of CommonJS and ES6 modules and module syntax, the internal module pattern should be avoided.
Use external commonJS/ES6 modules instead of TypeScript namespeces.
Don't use IIFE (Immediatly-Invoked Function Expression)
IIFEs are common in JavaScript development as they allow you to encapsulate your code.
(function() {
// encapsulated closure protected from other code
})();
Using external modules eliminates the need for explicity wrapping your code in IIFE closures. Instead a build time task (browserify, jspm or webpack) and module system will bundle and load your code into closures.
IIFEs will also cause problems exporting types from your modules.
Only create AngularJS Modules for a purpose
Keep the number of AngularJS modules to the minimum needed. Have a reason for creating new AngularJS modules.
Such as:
- Sharing common code among different applications
- Making code more testable
AngularJS modules were created because of the lack of module system in JavaScript. With ES6 module syntax and commonJS modules AngularJS internal modules are a legacy artifact of AngularJS 1.x.
Define Services as Classes
Defining your services as classes, which AngularJS's DI mechinism will instantiate as singletons, will make your code cleaner, easier to read, easier to maintain and easier to test.
Lastly, defining your service as a class will make your code more friendly to static typing
with TypeScript.
// userProxy.ts
import {app} from '../app';
export class UserProxy {
static $inject = ['$http'];
constructor(private $http: ng.IHttpService) {}
login(login: UserLogin) {
return this.$http.post('/api/login', login);
}
logout() {
return this.$http.post('/api/logout', {});
}
}
app.service('userProxy', UserProxy);
import {app} from '../app';
import {UserProxy} from './userProxy';
export default class LoginController {
static $inject = ['userProxy'];
constructor(private userProxy: UserProxy) {}
model: UserLogin = {};
submit(model: UserLogin) {
this.userProxy.login(model).then(
() => {
// handle success
},
() => {
// handle error
});
}
}
app.controller('LoginController', LoginController);
In the previous example we demonstrate clean type encapsulation using imports.
- We import the type
import {UserProxy} from './userProxy';
- Then we use the imported type in the constructor injector,
constructor(private userProxy: UserProxy) {}
- Finally we demonstrate use of the static typing that was imported
this.userProxy.login(model).then(
This is a much more manageable approach than using TypeScript internal modules/namespaces to access types.
Only use the Factory Method when Needed
In most cases your services will be singletons. Use the service
method to register these with AngularJS's DI, app.service('userProxy', UserProxy)
.
The obvious use case for the factory
method is when using the factory pattern. Let's use the previous UserProxy
class as an example. For this example let's assume there are more than one JSON/HTTP end points that impliment this same API. We can make this class reusable with a factory.
Lets update the class to look like this:
// userProxy.ts
import {app} from '../app';
export class UserProxy {
constructor(private $http: ng.IHttpService,
private basePath: string) {}
login(login: UserLogin) {
return this.$http.post(this.basePath + '/login', login);
}
logout() {
return this.$http.post(this.basePath + '/logout', {});
}
}
We can now use a factory to create instances of this class:
// userProxyFactory.ts
import {app} from '../app';
import {UserProxy} from './userProxy';
export UserProxy;
export type userProxyFactory = (basePath: string) => UserProxy;
userProxy.$inject = ['$http'];
function userProxy($http: ng.IHttpService): userProxyFactory {
return (basePath: string) => {
return new UserProxy($http, basePath);
}
}
app.factory('userProxy', userProxy);
This can then be used in the controller as:
import {app} from '../app';
import {userProxyFactory, UserProxy} from './userProxyFactory';
export default class LoginController {
private userProxy: UserProxy
static $inject = ['userProxy'];
constructor(userProxyFactory: userProxyFactory) {
this.userProxy = userProxyFactory('/api1');
}
model: UserLogin = {};
submit(model: UserLogin) {
this.userProxy.login(model).then(
() => {
// handle success
},
() => {
// handle error
});
}
}
app.controller('LoginController', LoginController);
Bind Directly to Controller Properties and Methods
Many examples still bind methods and properties to the injected $scope
within the controller.
// DON'T DO THIS
export class PersonController {
static $inject = ['$scope'];
constructor(private $scope: ng.IScope) {
$scope.name = "Person's Name";
$scope.save = () => {
// . . .
}
}
}
This is a bad practice for a few reasons.
- This has a memory impact. Every instance of this controller will have it's own copy of each method bound to the scope. If the methods were instead defined as instance methods the implimentation will be shared across instances.
- The API for this class is not exported and usable in unit tests.
- With larger nested applications you will run into scope inheritance colisions. These colissions will cause strange behaviour that can seem to defy reason. They are often hard to track down.
- It becomes easier to pass a value by reference when you include the properties parent object in the expression,
person.name
rather thanname
.
Instead define the above controller like this:
export class PersonController {
name = "Person's Name";
save() {
// . . .
}
}
With ng-router
and ui-router
you will just need to name your instance of the controller with the controllerAs
configuration property.
Use TypeScript for Unit Tests
One of the main advantages of using TypeScript are the type annotations for the classes you want to test. Fear of breaking tests should not prevent you from refactoring rotting code. Using types with your tests will help keep your tests clean and readable.
Summation
TypeScript can be a great tool to keep your code base and easier to refactor. Your services will have solid APIs that your controllers and components will consume. Bugs will be found at compile time rather than in QA or Production.
Even without the TypeScript, many of these practices can be applied to both ES6 and ES5. In ES5 you will just need to use commonJS require
syntax instead of the ES6 import
systax -- and the JavaScript Prototype Pattern instead of the class
keyword.
Just a couple final thoughts:
- Use a build system -- gulp or grunt.
- Use NPM -- don't use bower. You only need one JavaScript package manager system. Bower is redundant.