Click here to Skip to main content
13,202,151 members (66,972 online)
Click here to Skip to main content
Add your own
alternative version

Stats

4.7K views
Posted 25 Nov 2015

Re-enabling Typescript in Meteor with help of ES7 decorators

, 25 Nov 2015
Rate this:
Please Sign up or sign in to vote.
Collection of TypeScript decorators for MeteorJs.

Introduction

This article demonstrates how you can improve way of working with TypeScript in MeteorJs by using TypeScript decorators.

Background

TypeScript adds great intellisense, static typing and a lot of other useful features to JavaScript. I use it for several years and with great success.

However, all this awesomeness doesn't work "out of the box" with MeteorJs. For example, consider calling Meteor methods. You have to use Meteor.call and pass a string identifier of the method, instead of just calling method directly! Obviously, you cannot benefit from TypeScript here.

Dynamic lists of parameters, and magic global strings are generally in the great fashion in Meteor, as well as binding current context or other useful stuff to this variable of the functions. These things are used so widely, that TypeScript intellisense almost never works and most of its benefits are just wasted.

Also cannot resist mentioning, that to my C#-hardened eye, the final code, with all these uncountable curly brackets, looks quite disheveled and messy. So I had to do something. And I did! :)

The transformation

I was able to transform my messy Meteor code into readable and flexible classes, with full intellisense and ability to call methods directly instead of using wrappers like Meteor.call.

So for example, my server code for publishing posts-related data looked something like this:

Meteor.publish('postsOfTopic', function(topicId: string, page: number) {
    return [
        Posts.find({ topicId: topicId },
                   { sort: { date: 1 }, skip: perPage*(page-1), limit: perPage }),
        Likes.find({ topicId: topicId })
    ]
});

it was transformed into this:

class PostsController
{
    @publish
    public static subscribeToPostsOfTopic (topicId: string, page: number)
    {
        return [
            Posts.find({ topicId: topicId }, 
                       { sort: { date: 1 }, skip: perPage*(page-1), limit: perPage }),
            Likes.find({ topicId: topicId })
        ];
    }

    // ... other methods ...
}

this.PostsController = PostsController;

As you can see, instead of combination of Meteor.publish, "magic string" value and anonymous function, I now can use just a normal method inside a class. Everything else is hidden under the @publish decorator.

The route that uses this subscription, has changed as well:

Was:

Router.route('/forum/topics/:_id', function() {

    var topicId = this.params._id;
    var page = this.params.query.page || 1;

    Meteor.subscribe('postsOfTopic', topicId, page);
    Meteor.subscribe('postsOfTopic_count', topicId);

    this.render("posts", {
        data: function() {
            return {
                // ... skipped ...
            };
        }
    });
    
});

Became:

class PostsRoutes
{
    @route("/forum/topics/:_id")
    public static showPostsOfTopic(routeInfo: RouteInfo)

        var topicId = routeInfo.params['_id'];
        var page = routeInfo.params.query['page'] || 1;

        PostsController.subscribeToPostsOfTopic(topicId, page);
        PostsController.subscribeToPostsOfTopic_count(topicId);

        routeInfo.render("posts", {

            data: function() {
                return {
                    // ... skipped ...
                };
            }
        });
    }

}

Notice that subscriptions are now performed as direct method calls. This allows for example easily renaming them, not caring about changing the hardcoded string values. And of course this way I have helpful hints of the parameters of these methods.

Also notice that routeInfo parameter is now used instead of this. Because for a parameter I can define it's type, so that it receives intellisense.

In order to understand how it is implemented, here is a very brief introduction to ES7 decorators:

TypeScript Decorators

Typescript decorators are the perfect candidate for marking methods to play a special role, like the role of Meteor methods or template helpers.

Decorators were added since TypeScript 1.5 and actually are part of ES7 proposal. You also may have heard that AngularJs 2 actively uses them. They are also the direct analogue of attributes in C#.

Here's how a decorator looks in TypeScript:

class MyClass {

   @log
   public MyField: string;

}

@log is the decorator.

Implementation of the decorator is simply a function:

function log(target, propertyKey, descriptor)
{
    console.log(target); // will log the MyClass object
    console.log(propertyKey); // will log "MyField"
    console.log(descriptor); // will log the descriptor of MyField property
    return descriptor;
}

This function is automatically executed when the decorated class or method is created.

The return descriptor piece is particularly interesting, because it essentially allows wrapping or completely replacing the decorated method with something else. So decorators not only provide opportunity to execute some code when a method is defined, but also change the behavior of this method.

More details on parameters can be found in reference of the Object.defineProperty method. Also, parameters can be different if the decorated target is not a property, but a class or a parameter.

List of decorators

Here are the decorators that I implemented so far:

  1. @publish - replaces Meteor.publish
  2. @method - replaces Meteor.methods
  3. @route - replaces Router.route (Iron Router)
  4. @helper - replaces Template.<name>.helpers
  5. @eventHandler - replaces Template.<name>.events

Now let's go through the decorators one by one:

@publish

publish = function(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
    var originalMethod = descriptor.value;
    var publicationName = target.toString().slice(9,-5) + "." + propertyKey;
    
    if (Meteor.isServer)
    {
        Meteor.publish(publicationName, originalMethod);
    }
    descriptor.value = function(...args: any[]) {
        args.unshift(publicationName);
        return Meteor.subscribe.apply(target, args);
    };

    return descriptor;
}

The hacky fragment

target.toString().slice(9,-5)

simply produces the name of the current class.

So publicationName will be for example "PostsController.subscribeToPostsOfTopic". Adding class name to the method name is important, because Meteor publications (as well as methods) are global.

Having the appropriate name, we now can publish our method with Meteor.publish:

if (Meteor.isServer)
{
    Meteor.publish(publicationName, originalMethod);
}

Next, we replace method with our wrapper method, so that whenever it gets called, Meteor.subscribe will be called instead of directly calling the method:

descriptor.value = function(...args: any[]) {
    args.unshift(publicationName);
    return Meteor.subscribe.apply(target, args);
};

args.unshift piece precedes the initial arguments with the publication name, as Meteor API requires, so that a call like this:

PostsController.subscribeToPostsOfTopic(topicId, page);

During execution time will be transformed into this:

Meteor.subscribe("PostsController.subscribeToPostsOfTopic", topicId, page);

apply ensures that this context is the PostsController class rather than anything else.

@method

method = function(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
    var originalMethod = descriptor.value;
    var methodName = target.toString().slice(9,-5) + "." + propertyKey;

    if (Meteor.isServer)
    {
        var methodsObj: any = {};
        methodsObj[methodName] = originalMethod;
        Meteor.methods(methodsObj);
    }

    descriptor.value = function(...args: any[]) {
        args.unshift(methodName);
        return Meteor.call.apply(target, args);
    };

    return descriptor;
}

As you can see, @method decorator is very similar to @publish, with minor difference that we should pass a dictionary to Meteor.methods instead of single parameters.

@route

This decorator has slightly different structure, because it has arguments. In this case decorator implementation function acts like a factory of decorators based on supplied parameters:

route = function(url: string) {
    return (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<Function>) => {
        Router.route(url, function() {
            descriptor.value.call(target, this);
        });
        return descriptor;
    };
}

The decorator itself is very simple. It calls Iron Router's Router.route method with the url parameter and a function that calls our original method.

Hopefully it is clear that target (e.g. the PostsRoutes class) becomes this in the original method, and this from the Router.route becomes first parameter, which I usually call routeInfo:

@route("/forum/topics/:_id")
public static showPostsOfTopic(routeInfo: RouteInfo): void

In order to make intellisense work better, I added two simple interfaces into my ironrouter.d.ts:

interface RouteParams {
    [key: string]: any,
    query: {
        [key: string]: string
    },
    hash: {
        [key: string]: string
    }
}

interface RouteInfo {
    render(templateName: string, options?: any): void;
    params: RouteParams;
}

That's it for the @route.

@helper

helper = function (templateName?:string)
{
    var templateNameParam = templateName;
    var noParams = arguments.length > 0 && typeof arguments[0] != 'string';
    if (noParams)
        templateNameParam = null;
    
    var helperDecorator = function (target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
        if (Meteor.isClient)
        {
            var helpersObj: any = {};
            if (!templateNameParam && target.constructor.name.endsWith("Template"))
            {
                templateNameParam = target.constructor.name.slice(0,-8);
                templateNameParam = templateNameParam.substr(0, 1).toLowerCase() + templateNameParam.substr(1); 
            }
            if (!templateNameParam)
                throw new Error("Please specify templateName for @helper decorator of " + propertyKey + " method!");
                
            helpersObj[propertyKey] = function(...args: any[]) {
                args.unshift(this);
                return descriptor.value.apply(target, args);
            };
            Template[templateNameParam].helpers(helpersObj);
        }
        return descriptor;
    };
    
    if (noParams)
        return helperDecorator.apply(this, arguments);
    else
        return helperDecorator;
    
}

@helper is a bit tougher to grasp.

One thing you should know immediately about it is that @helper decorator can be used either with parameters or without them. So it actually implements two behaviors depending of it's arguments.

This is how I determine the distinction:

var noParams = arguments.length > 0 && typeof arguments[0] != 'string';

If @helper is not provided a parameter, it should be declared in a class with name <something>Template:

class PostTemplate
{
   
    @helper
    public userCanEdit(post: Post)
    {
        return Meteor.user() && (Roles.userIsInRole(Meteor.userId(), SiteRoles.admin) || Meteor.userId() == post.authorId);
    }

    // ... skipped ...

}

In this example you can see that name of the class is PostTemplate, so the decorator will infer that template name is "post". Notice that first letter gets lowercased automatically:

templateNameParam = templateNameParam.substr(0, 1).toLowerCase() + templateNameParam.substr(1); 

So for example if you class is called PostFormTemplate, then the template name will be inferred as "postForm".

Of course, the template name is necessary in order to call the Meteor's Template.<name>.helpers.

Another way to use @helper is to provide it with template name manually. It is very handy when you have several small templates and don't want to create a class for each of them:

class SectionsPage
{
    
    @helper("section")
    public shorten(section: Section, text: string)
    {
        if (text == null)
            return "";
        return text.length < 30 ? text : text.substr(0, 30) + "..."; 
    }

    @helper("sectionsButtons")
    public editModeForButtons (context: any) 
    {
        return _editMode.get();
    }

    // ... skipped ...
}

Another important thing about usage of this decorator is that it also, same as @route, pops the usual this variable into a parameter. We can thus have full-scaled TypeScript intellisense inside helper method, which is very nice feature to have! :)

@eventHandler

eventHandler = function(selector: string, templateName?: string) {
    return (target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) => {
        if (Meteor.isClient)
        {
            var eventsObj: any = {};
            if (!templateName && target.constructor.name.endsWith("Template"))
            {
                templateName = target.constructor.name.slice(0,-8);
                templateName = templateName.substr(0, 1).toLowerCase() + templateName.substr(1); 
            }
            if (!templateName)
                throw new Error("Please specify templateName for @eventHandler decorator of " + propertyKey + " method!");

            eventsObj[selector] = function(...args: any[]) {
                args.unshift(this);
                return descriptor.value.apply(target, args);
            };
            Template[templateName].events(eventsObj);
        }
        return descriptor;
    };
}

@eventHandler is again pretty much similar to the other decorators above. It can accept one or two arguments, first being always the selector, e.g. "submit form" or "click #button-ok", and the second parameter being template name. Similarly to @helper, it can deduce template name from the class name, if it ends with "Template".

The code

Please feel free to use and modify the code as you feel necessary!

Download decorators.zip

Also you can browse this file online using "Browse" button to the right.

Conclusion

The described above approach, at least for me, turns out to be a very convenient and flexible way to use TypeScript fully when doing MeteorJs. It returns the TypeScript benefits, enables full intellisense, but also preserves flexibility: you can have how many classes you want and group your helpers and methods according to your own preferences.

This approach obviously can also be applied to other Meteor and community features, in order to "TypeScriptise" them.

If you have more ideas or ideas how to make those decorators even better, please welcome to comments section! :)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Andrei Markeev
Finland Finland
Full stack developer, enterprise web software. Microsoft MVP, open source person, speaker, online expert.

You may also be interested in...

Pro

Comments and Discussions

 
PraiseBrilliant idea! Pin
Dominik Schröter26-Jan-16 22:34
memberDominik Schröter26-Jan-16 22:34 
GeneralRe: Brilliant idea! Pin
Andrei Markeev12-Oct-16 6:17
memberAndrei Markeev12-Oct-16 6:17 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Terms of Use | Mobile
Web03 | 2.8.171020.1 | Last Updated 25 Nov 2015
Article Copyright 2015 by Andrei Markeev
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid