Create a simple API framework using Decorators in TypeScript
In TypeScript version 5, the team finally implemented a non-experimental support for Decorators. The experimental support has been around for years, and can easily be activated by setting the experimentalDecorators flag to true in the TSConfig file. But now, they decided to make it part of the regular framework.
As a developer with large history in Java Spring, I have a great fondness for annotations (decorators) - and I am so pleased to see that this feature is now available with proper support from the team.
What are decorators?
Like much in the JavaScript/TypeScript ecosystem, they are just functions. But not any function. Decorators allow us to modify function behaviour, append properties to a class, add side effects and much more. The functions are evaluated at runtime.
Decorators can be applied to functions, classes, accessors and in parameters. In this guide, we will take a look at Class decorators and Method decorators.
To read more about decorators, check out TypeScript handbook. And if you want more examples using older versions of TypeScript, we have a guide on experimental decoerators as well.
What we will cover
In this guide, we will create a simple web API framework using Express. We will implement support for registering a controller class to the routes, and to add GET and POST methods to the API - using decorators.
When we are finished, we will be able to setup our API like below example:
import { Get, Post, Controller, start } from "./web-framework"; @Controller("/api") class UserController { @Get("/user") getUser() { return { name: "John Doe", age: 25, }; } @Post("/user") addUser(body: any) { console.log(body); return { message: "User added", }; } }
So basically, we will in a simple manner, registering a class to be a controller with a specific path, and then add sub paths to our class methods and also which http method they should have.
Creating our web framework
Let's start simple. Create a file called web-framework.ts. Then, we will import our dependencies, which will be express and body-parser.
import bodyParser from "body-parser"; import express, { Request, Response } from "express"; const PORT = 3000; const app = express(); app.use(bodyParser.json());
Then, we will setup a map which will hold information about our controllers and their respective path.
const controllerPaths = new Map<any, string>();
@Controller
Now, we will create our first decorator 🎉 We will call it Controller, and it will be responsible of adding the class and the path to our map.
export function Controller(path: string) { return function (target: any, context: any) { controllerPaths.set(target, path); }; }
Above function will register the class to our Map and add the whole constructor as key, and the path as the value.
_getControllerPath()
We will create a helper function that will take in a target and a path as arguments, then make some checks to see if the controller is setup properly and return the full path of our route. This helper function will be used in the next step where we will implement the @Get and @Post decorators.
const _getControllerPath = (context: any, path: string) => { const controllerPath = controllerPaths.get(context.constructor); if (!controllerPath) { throw new Error("Controller path not found"); } return controllerPath + path; };
@Get
Let's create our Get decorator. This function will get the full path from our previous function, and then simply add the route and handler function to the express app.
export function Get(path: string) { return function (target: any, context: any) { context.addInitializer(function (this: any) { const fullPath = _getControllerPath(this, path); app.get(fullPath, (req: Request, res: Response) => { const result = target.call(this); res.json(result); }); }); }; }
NOTE: that we are using a built in function in the context, addInitializer. What this function is doing, is basically adding the possibility to hook into the initialization process of the class that the method belongs to. So in our case, when we are doing new UserController(), this function will be called.
@Post
Similar to the Get decorator, our Post will do the exact same, but instead registering a post method to the express app, and pass along the body to the target method.
export function Post(path: string) { return function (target: any, context: any) { context.addInitializer(function (this: any) { const fullPath = _getControllerPath(this, path); app.post(fullPath, (req: Request, res: Response) => { const result = target.call(this, req.body); res.json(result); }); }); }; }
Start application
And when the start() function is getting called, we need to loop through all constructors in the map, and initialize them. And also, starting the express app on the specified port.
export function start() { for (const Controller of controllerPaths.keys()) { new Controller(); } app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); }
There we go. Now we are ready to use our cool decorators to build our API.
Server
Let's create a new file called server.ts. Now we will implement two controllers with some functions, both GET and POST. For this example, we will only use static data, but for a production application you would probably add some database handling or other cool business logic 🤓
import { Get, Post, Controller, start } from "./web-framework"; @Controller("/api") class UserController { @Get("/user") getUser() { return { name: "John Doe", age: 25, }; } @Post("/user") addUser(body: any) { console.log(body); return { message: "User added", }; } } @Controller("/api") class AdressController { @Get("/address") getAddress() { return { street: "123 Fake St", city: "Springfield", state: "IL", }; } } start();
And if we run this with command ts-node server.ts, we should have three endpoints ready to be used.
Example calls:
GET http://127.0.0.1:3001/api/user GET http://127.0.0.1:3001/api/address POST http://127.0.0.1:3001/api/user Body: { name: "John Die", "age": 28 }
Responses:
{ "name": "John Doe", "age": 25 }
{ "street": "123 Fake St", "city": "Springfield", "state": "IL" }
{ "message": "User added" }
Summary
In this tutorial, we explored how to build a custom made framework using decorators in TypeScript. Our framework are highly inspired by Nest.js and Spring boot 😉 Decorators are so cool, and allow us to build slim and reusable code that we can add in a nice and sleek way into our code base.
I hope you enjoyed this one, and stay tuned for more of these tutorials.