jwa
jwa

Reputation: 3281

AngularJS/Typescript Integration Pattern - Scope Methods

I am attempting change the way I am writing AngularJS apps from a plain-javascript to using TypeScript as a pre-processor.

I am struggling to reconcile the two approaches when it comes to scoped method calls.

For illustrative purposes, let's consider the common menu use-case; I wish to highlight a specific menu item which is currently displayed. The HTML template looks like this:

<ul class="nav navbar-nav">
  ...
  <li ng-class="{active: isSelected('/page1')}"><a href="#/page1">Page 1</a></li>
  ...
</ul>

This anticipates a scoped function called isSelected. With old-school javascript coding, I' write this as:

$scope.isSelected = function(path) {
  return $location.path().substr(0, path.length) == path;
}

This anonymous function declaration doesn't really seem to honor the more traditional class model of TypeScript. In typescript, I find myself tempted to write this:

export interface MenuScope extends ng.IScope {
    isSelected(path: String): boolean;
}

export class MenuController {

    location: ng.ILocationService;

    scope: MenuScope;

    constructor($scope: MenuScope, $location: ng.ILocationService) {
        this.scope = $scope;
        this.location = $location;

        this.scope.isSelected = function(path) { return this.isSelected(path) }.bind(this);
    }

    isSelected(path: String): boolean {
        return this.location.path().substr(0, path.length) == path;
    }
}

In this case, isSelected belongs to the controller, rather than the scope. This seems sensible. However, the "link" between the scope and controller still relies on an anonymous method.

Even worse, I've had to explicitly bind the context of this to ensure I can write this.location to access the location service in the implementation of isSelected().

One of the benefits I am looking for from TypeScript is a clearer way of writing code. This indirection through a binded anonymous function seems to be the antithesis of this.

Upvotes: 2

Views: 857

Answers (3)

alisabzevari
alisabzevari

Reputation: 8146

Here is a simple app with a controller and a service. I use this style in my projects:

/// <reference path="typings/angularjs/angular.d.ts" />
module App {
    var app = angular.module("app", []);
    app.controller("MainController as vm", Controllers.MainController);
    app.service("backend", Services.Backend);
}

module App.Controllers {
    export class MainController {
        public persons: Models.Person[];

        static $inject = ["$location", "backend"];
        constructor(private $location: ng.ILocationService, private backend: Services.Backend) {
            this.getAllPersons();
        }

        public isSelected(path: string): boolean {
            return this.$location.path().substr(0, path.length) == path;
        }

        public getAllPersons() {
            this.backend.getAllPersons()
                .then((persons) => {
                    this.persons = persons;
                })
                .catch((reason) => console.log(reason));
        }
    }
}

module App.Services {
    export class Backend {
        static $inject = ["$http"];
        constructor(private $http: ng.IHttpService) { }

        public getAllPersons(): ng.IPromise<Models.Person[]> {
            return this.$http.get("api/person")
                .then((response) => response.data);
        }
    }
}

module App.Models {
    export interface Person {
        id: number;
        firstName: string;
        lastName: string;
    }
}
  1. I have modules of app, controllers, services and models.
  2. controller defined as a class but must be registered to app through controller as syntax. So everything you define in class is accessible through vm in the view (controller scope). Here we have persons, isSelected and getAllPersons.
  3. You can inject every injectable through static $inject that is a string[], then add them as constructor parameters respectively. This role is also usable when defining services and it is minifiable.
  4. You can also inject $scope to your controller class to access scope specific tools such as $apply, on etc.
  5. Instead of defining factories you can define services to be able to define them as a class.
  6. Injecting in services is the same as injecting in controllers.
  7. You can define return type of you http calls as ng.IPromise<Model> then return response.data to ensure that type of your return method is just entities, not http related data.

Upvotes: 2

Patrick
Patrick

Reputation: 6958

We were considering a similar conversion (e.g. from Javascript to Typescript for Angular). There were certain things (like your example) that looked very odd as we started to implement. The quickest way to go from what you have is to use the controller as syntax. This way, you expose methods directly on the controller.

<!-- use controller as syntax -->
<div ng-controller="MenuController as menu">
<ul class="nav navbar-nav">
  ...
  <li ng-class="{active: menu.isSelected('/page1')}"><a href="#/page1">Page 1</a></li>
  ...
</ul>
</div>

This would allow you to get past the need to bind the scope's method back to that on the controller. Things I don't like about this approach:

  • Each injectable (e.g. $scope, $location) is now available directly through the controller. This might not be a big deal, but seems undesirable when you want to know exactly what the controller can do and keeping things properly scoped.
  • The generated code of classes seems overly cluttered and not optimized for minification... This is more of a pet peeve of mine where you trade the ease of using something familiar like class for code that still has room for optimization. See generated code for Inheritance at the Typescript Playground (extends function generated for each .js file where you want to extend a class unless you have your references are on point, the prototype of the function could be cached to add methods to it instead of ClassName.prototype.method for each and every method...), but I digress

The other option is to not use classes, but to stick to strongly typed functions:

export function MenuController($scope: MenuScope, $location: ng.ILocationService): any {
    $scope.isSelected = function(path:string): boolean {
      return $location.path().substr(0, path.length) == path;
    }
}

Since angular is responsible for instantiating the controller, the return type any doesn't matter. But you could get caught if you had a typo on your $scope's method.

To get around this, you can go a step further by using controller as syntax. The example below won't let you actually new up your controller yourself (e.g. new MenuController($location) would fail with only void function can be called with new keyword), but this is negligible since angular handles the instantiation for you.

export interface IMenuController {
    isSelected(path: string): boolean;
}
export function MenuController($location: ng.ILocationService): IMenuController {
    var self:IMenuController = this;

    self.isSelected = function(path:string): boolean {
      return $location.path().substr(0, path.length) == path;
    }

    // explicitly return self / this to compile
    return self;
}

TL DR: I'm a fan of the compile time checking of types, and would love to use the concept of class. However, I don't think it completely fits with the Angular 1.x model. It seems like this is designed to work for Angular2. Use strongly typed functions instead for Angular 1.x.

Upvotes: 1

yotamN
yotamN

Reputation: 771

You shouldn't store your $scope as a variable in this but instead use this as the $scope, the way to do this is to do $scope.vm = this; in the beginning of the constructor, now every function and variable of the class will be part of the $scope.

You can't avoid this.location = $location because this is the syntax of TypeScript.

BTW you should use $inject for the dependencies.

Upvotes: 2

Related Questions