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 })
];
}
}
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 {
};
}
});
});
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 {
};
}
});
}
}
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);
console.log(propertyKey);
console.log(descriptor);
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:
- @publish - replaces Meteor.publish
- @method - replaces Meteor.methods
- @route - replaces Router.route (Iron Router)
- @helper - replaces Template.<name>.helpers
- @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);
}
}
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();
}
}
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! :)