Decorators can be a useful tool when writing TypeScript applications. One way I like to use them for is for creating node-based MVC web application where decorators provide a convenient tool for routing. Think of something like this:
@Controller('/user')
class UserController {
@Route('/')
public index() {
// return proper response
}
@Route('/:name')
public details() {
// return proper response
}
}
This would provide routes prefixed with /user
, while /user
may lead to a user overview and /user/:name
lead to user details. This concept may look familiar if you're used to Symfony Routing or NestJS Controllers.
Introduction
In this post we're going to build an Express application with TypeScript using ts-node (which makes it possible to run TypeScript applications without having to compile our .ts
files manually).
You could use any underlying framework you want, e.g. koa, hapi or even your very own http server implementation. Differences should only occur when actually registering route, everything else should remain the same. The only reason why I've chosen express is because I have the most experience with it - but feel free to use whatever you want.
In case you're looking for a TL;DR: you can find the entire source code for this post on GitHub.
Architecture
There are different ways to implement routing capabilities with TypeScript. But before diving right into the implementation it's important to keep some things in mind.
The first important information is:
Decorators are called when a class is declared, not when it's instantiated.
So when decorating our method we do not have an instantiated object to work on inside our decorator. Instead we just have a class declaration which we can use. See here for detailed information about decorator evaluation order.
Since decorators are just functions they have their own scope. This becomes a bit problematic as soon as we realize that route registration for express takes place outside decorators:
One way to get our routes from the decorator to our express application would be introducing a registry class which would be filled by our decorator and read at some later point when registering our routes.
But there's an easier way to do this which involves the reflect-metadata library (which you're likely already using if you're dealing with decorators). Instead of using a separate layer (in form of a registry) we could simply attach routes to our controller metadata:
We simply save routes to our controller metadata. Later, when registering our routes in our express application, we already need to load our controllers - and that's where we simply read our route metadata and register them properly.
Knowing all of these things let's start implementing our routing decorators!
Express application
First of all we need to create our express application. In our first iteration we'll just serve a default route to test if everything works:
// index.ts
import 'reflect-metadata';
import {Request, Response} from 'express';
const app = express();
app.get('/', (req: Request, res: Response) => {
res.send('Hello there!');
});
app.listen(3000, () => {
console.log('Started express on port 3000');
});
reflect-metadata
only needs to be imported once per application, hence here's a good place to do so.
Start your server with ts-node index.ts
and head over to localhost:3000
to be friendly greeted by Obi-Wan.
Controller decorator
This decorator will be attached to our controllers and contain the prefix for this controller:
// Decorator/Controller.ts
export const Controller = (prefix: string = ''): ClassDecorator => {
return (target: any) => {
Reflect.defineMetadata('prefix', prefix, target);
// Since routes are set by our methods this should almost never be true (except the controller has no methods)
if (! Reflect.hasMetadata('routes', target)) {
Reflect.defineMetadata('routes', [], target);
}
};
};
A pretty simple class decorator which sets the prefix
metadata on the controller and, in case no routes
metadata has been found, sets it to an empty array. As stated in the comments routes
should almost never be undefined
, except our controller has no decorated methods.
Route decorator
It would be convienient to have a decorator for every HTTP verb, like @Get
, @Post
, etc.. For the sake of simplicity we're only implement the @Get
decorator:
// Decorator/Get.ts
import {RouteDefinition} from '..';
export const Get = (path: string): MethodDecorator => {
// `target` equals our class, `propertyKey` equals our decorated method name
return (target, propertyKey: string): void => {
// In case this is the first route to be registered the `routes` metadata is likely to be undefined at this point.
// To prevent any further validation simply set it to an empty array here.
if (! Reflect.hasMetadata('routes', target.constructor)) {
Reflect.defineMetadata('routes', [], target.constructor);
}
// Get the routes stored so far, extend it by the new route and re-set the metadata.
const routes = Reflect.getMetadata('routes', target.constructor) as Array<RouteDefinition>;
routes.push({
requestMethod: 'get',
path,
methodName: propertyKey
});
Reflect.defineMetadata('routes', routes, target.constructor);
};
};
Important: It's important to set our target
target.constructor
and not justtarget
to properly handle metadata.
Again, a pretty simple decorator which extends the stored routes on the controller by a new route. RouteDefinition
is an interface which defines the shape of our routes:
// Model/RouteDefinition.ts
export interface RouteDefinition {
// Path to our route
path: string;
// HTTP Request method (get, post, ...)
requestMethod: 'get' | 'post' | 'delete' | 'options' | 'put';
// Method name within our class responsible for this route
methodName: string;
}
Tip: In case you'd like to add some middleware before controllers it'd be a good idea to store it within the
RouteDefinition
.
Now we've got both of our required decorators and can get back to our express application to register our routes.
Registering routes
Before registering our routes to our express application let's implement a controller with our new decorators:
// UserController.ts
import {Controller} from '../src';
import {Get} from '../src';
import {Request, Response} from 'express';
@Controller('/user')
export default class UserController {
@Get('/')
public index(req: Request, res: Response) {
return res.send('User overview');
}
@Get('/:name')
public details(req: Request, res: Response) {
return res.send(`You are looking at the profile of ${req.params.name}`);
}
}
Heading to /user
should show a "User overview" message and /user/foobar
should show a "You are looking at the profile of foobar"message.
But before this fully works we need to tell express about our routes - so let's get back to our index.ts
:
import 'reflect-metadata';
import * as express from 'express';
import UserController from './example/UserController';
import {RouteDefinition} from './src';
const app = express();
app.get('/', (req: express.Request, res: express.Response) => {
res.send('Hello there!');
});
// Iterate over all our controllers and register our routes
[
UserController
].forEach(controller => {
// This is our instantiated class
const instance = new controller();
// The prefix saved to our controller
const prefix = Reflect.getMetadata('prefix', controller);
// Our `routes` array containing all our routes for this controller
const routes: Array<RouteDefinition> = Reflect.getMetadata('routes', controller);
// Iterate over all routes and register them to our express application
routes.forEach(route => {
// It would be a good idea at this point to substitute the `app[route.requestMethod]` with a `switch/case` statement
// since we can't be sure about the availability of methods on our `app` object. But for the sake of simplicity
// this should be enough for now.
app[route.requestMethod](prefix + route.path, (req: express.Request, res: express.Response) => {
// Execute our method for this path and pass our express request and response object.
instance[route.methodName](req, res);
});
});
});
app.listen(3000, () => {
console.log('Started express on port 3000');
});
And voilà, that's it. We now can navigate to /user
or /user/<name>
and get proper responses from our express application. Wohoo!
Advancing this approach
This is a very basic approach which contains lots of room for improvement when it comes to the actual implementation. Here are some thoughts on improving this implementation:
Instantiation
Our controller is naively instantiated with new controller()
. But what if our controller does have some constructor
arguments?
This would be a perfect use case to apply some dependency injection as described in a former post which would be plugged in right at where our controller is instantiated.
Return values
I'm not a huge fan of res.send()
- instead it would be pretty convinient if responses could be simple objects which reflects their content (think of something like return new JsonResponse(/* ... */)
). This could easily be done by implementing such response objects and return them from our methods - later, when registering our routes, the callback would still send
our response:
app[route.requestMethod](prefix + route.path, (req: express.Request, res: express.Response) => {
const response = instance[route.methodName](req, res);
res.send(response.getContent()); // where `getContent` returns the actual response content
});
This would require additional validation of our return value, especially to prevent express being stuck due to the lack of a next()
call (which is covered by send
but needs to be called manually if you don't utilise send
).
Conclusion
As you've just seen it's pretty simple to handle routing via TypeScript decorators without having too much magic going on or having to install frameworks or any additional libraries.
As always the entire source code (including tests) for this post can be found on GitHub.
If you've any other approaches for achieving this or have any thoughts on the approach described in this post just leave a comment!