Routing with TypeScript decorators for node applications

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:

routing-arch-2

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:

routing-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 just target 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!