Typescript for Strapi! Life’s too short to ES5!

If you fancy it, check out the same post on my personal blog.

Strapi is my headless CMS of choice if I want to build an API for a mobile / web application. It’s open source, extensible & built on top of Koa. It also has loads of useful features you would expect from a powerful CMS, like: content manager & model builder, authentication & security features, role & permission management, plugin support and much more.

However, it has one major drawback: it does not support Typescript (as of the writing of this article, it’s one of the most popular features on their product board’s under consideration section).

Therefore, I had to find a way to make it, at least from a development perspective, support this beautiful flavor of Javascript, which is Typescript.

Intro

  • admin: the administration module; since beta — if I’m not mistaken — Strapi adopted a more modular approach to deliver its functionality (no business editing anything here)
  • api: here lies the logic of our app, it's where we implement our APIs (it's also what we're interested in for the purpose of this article)
  • config: the API's config (routes, policies etc.)
  • controllers
  • models
  • services: your custom services
  • build: the “compiled” UI of the admin interface
  • config: (mostly) JSON config files for the app e.g. database, security etc. (you can also include your custom files & config here)
  • extensions: customization config for your plugins, including admin; read more about it in the official docs
  • node_modules: no need to talk about this usual suspect, I guess
  • plugins: the code for the installed plugins, again modular
  • public: your app's frontend, together with its assets (if it has one)

Also have a look at the file structure in the official docs.

Objective

Step 1: rewrite your *.js to *.ts

If you do have a large codebase you want to convert or even if you don’t you’ll find this guide from typescriptlang.org about migrating to Typescript very helpful.

Step 1.1: A place for our shiny new Typescript code

We will everything, keeping the same folder structure. Let’s take one of my APIs as an example, userprofile; this is how the folder structure looks like before rewriting:

And this is how it will look like after (don’t worry about creating all the files just now, just have in mind that this is our goal):

Step 1.2: Some boilerplate

To overcome this, we just need to create a dummy type information file: api_ts/strapi.d.ts; I populated it with the following fields, you may need to add others depending on your code:

declare namespace strapi {
const config;
const plugins;
const services;
const utils;
}

Step 1.3: (Optional) Generating interfaces from models

I added the following line to my package.json scripts:

"gen-ts-interfaces": "sts api/ -o ts/models/"

I personally didn’t feel the need to do this, but I may in the future.

Step 1.4: Rewriting controllers

Don’t worry, I have a little trick up my sleeve to solve just that. We’re going to use good old dependency injection for that, I chose tsyringe:

npm i --save-dev tsyringe

I hinted earlier that controllers will become classes; let’s take another concrete example:

Snippet from api/userprofile/config/route.json; our user profile confirm endpoint:

{
"method": "GET",
"path": "/confirm_email",
"handler": "UserProfile.confirm",
"config": {
"policies": []
}
}

Our api/userprofile/controllers/UserProfile.js:

...
const mailService = require('../../message/services/Mail.service');
...
module.exports = {
confirm: async (ctx) => {
... // the body of the function
}
}

Becomes api_ts/userprofile/controllers/UserProfile.ts:

...
import 'reflect-metadata';
import { autoInjectable } from 'tsyringe';
import { MailService } from '../../message/services/Mail.service'; // serves purely as injection example@autoInjectable() // informs tsyringe that this class needs to have its constructor members injected automatically
class UserProfileController {
constructor(
...
private mailService?: MailService, // example: this is how we inject a service
) {}
...
async confirm(ctx) {
... // the body of the function
}
}
module.exports = new UserProfileController(); // VERY IMPORTANT!

Let’s break down what we did here, it may look complicated but it’s actually very straightforward. After I came up with the approach, it took me under one minute to convert each controller, depending on the amount of code I had to copy paste:

  1. The module becomes a class
  2. The module exports become functions in that class
  3. If we required services / other classes, we can inject them in the constructor
  4. We need to export an instance of the class (this is only necessary for controllers)

By now you’re probably skeptical about this: I was exporting an object and now I’m exporting an instance of a class? What is happening here?

Well, the beauty of Javascript is that almost everything is an object. Therefore Strapi will be able to call module.confirm in either of those situations.

Advice from my refactoring experience

Just dummy import everthing else, even if it doesn’t exist yet, just pretend it does. E.g. in the example above, I wrote this:

import { MailService } from '../../message/services/Mail.service';

before the file actually existed.

In the end, some of my helper functions became services (needed injection) and some of my services became helper functions.

Step 1.5: Rewriting everthing else

I called it boring because there are no further tricks involved; Strapi does not care about your services, helper functions or whatever else you want to use in your implementation. Therefore, just go ahead and convert it in any way you please, as long as it’s written in Typescript and you import your files correctly, it’s fine.

Step 1.6: (Optional) Bonus: rewriting tricky policy functions

This is my api/userprofile/config/polcies/isKnownSender.js:

const userProfileService = require('../../UserProfile.service');module.exports = async (ctx, next) => {
const user = await userProfileService.getUserByFacebookSenderId(ctx.request.header['facebooksenderid']);
...
}

This is how it looks like after rewriting to api_ts/userprofile/config/polcies/isKnownSender.ts:

import { autoInjectable } from 'tsyringe';
import { UserProfileService } from '../../services/UserProfile.service';
@autoInjectable()
export class IsKnownSenderService {
constructor(private userProfileService?: UserProfileService) {
}
async isKnownSender(ctx, next) {
const user = await this.userProfileService.getUserByFacebookSenderId(ctx.request.header['facebooksenderid'];
...
}
}
const isKnownSenderService = new IsKnownSenderService();
module.exports = isKnownSenderService.isKnownSender.bind(isKnownSenderService);

Notice that we first create an instance of the class, then export the function we need & bind it to the instance.

We need a class because we want to inject the service. We bind the instance because we want to have access to the same this as the class does.

Step 1.7: (Optional) Bonus: should I convert everything to Typescript?

Furthermore, I decided that some APIs did not require rewriting; especially one of them that was exactly in the vanilla form generated by Strapi. I kept that one in ES5, since I’ll probably never touch that code.

You may encounter similar situations where you need to decide whether refactoring is the best way to go. What’s great is that you don’t have to go all the way: if you still want to have some of your code in ES5, and some of it in Typescript, it’s totally possible.

I think you’ll run into issues when you have dependencies between JS flavors (e.g. want to call something in Typescript from JS and vice versa), but I’m sure you’ll find workarounds for that when you need them.

Step 1.8: Gitignoring & removing generated code

If you take a look at the initial folder structure screenshots, you’ll notice that some files & folders inside api are yellowed out. That is how my IDE visually informs me that these files are not being tracked by git.

My approach:

  1. Remove all the api/**/*.js files rewtritten ts
  2. Gitignore them, and their generated declaration & map files; my .gitignore:
  • /api/**/*.js /api/**/*.d.ts /api/**/*.js.map
  1. Un-gitignore those controllers you want to keep in ES5, in my case it was the message controller:
  • !/api/message/controllers/* !/api/message/models/*

Step 2: Set up the Typescript transpiler

Step 2.1: Make sure you have typescript installed:

npm i --save-dev typescript

Step 2.2: Create the tsconfig.json config file for the Typescript transpiler.

{
"compilerOptions": {
"outDir": "./api",
"target": "es5",
"declaration": true,
"removeComments": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"noImplicitAny": false
},
"include": [
"./api_ts/**/*"
]
}

You may notice it contins a few extra compilerOptions than what you would usually see, let's dive into each of those:

  • outDir: we want our generated js, js.map & d.ts files inside api
  • targed: es5, to be compatible with Strapi
  • declaration: whether to export d.ts files or not; not sure if we need this actually
  • removeComments: pretty self-explanatory
  • experimentalDecorators: required for tsyringe to work
  • emitDecoratorMetadata: required for tsyringe to work
  • sourceMap: whether to export js.map files, required for debugging later
  • noImplicitAny: required for lazy people like me who don't want to type everything, but actually should, because what's the point of Typescript then?

The ./api_ts/**/* under include is just telling tsc that any ts file inside any folder in api_ts needs to be converted.

Step 2.3: Transpile

"build-ts": "tsc",
"watch-ts": "tsc -w"
  • npm run build-ts will transpile once
  • npm run watch-ts will transpile any time there are changes on api_ts/**/*

Step 3: Workaround for Strapi permissions

Long story short, Strapi is not aware that the implementation for these exists. Well actually it is, since it’s not spitting out an error informing you that there’s no such function, like it would do if you deleted the function completely from the class.

I haven’t dug very deep into this issue, but I suspect it’s because of the way Strapi generates its permission entities at startup. It’s probably just a shortcoming of the users-permissions plugin, since it wasn't designed to work like this.

Not to worry, I have an elegant solution for this as well. There’s this file config/functions/bootstrap.js that's called when Strapi boots up, where we can write our custom logic.

I wrote this nifty little piece of code in there.

'use strict';/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*
* See more details here: https://strapi.io/documentation/3.0.0-beta.x/configurations/configurations.html#bootstrap
*/
const find = require('lodash/find');
const fs = require('fs');
const fixPermissionsForTypescriptControllers = async () => {
const userPermService = strapi.plugins['users-permissions'].services['userspermissions'];
const roles = await userPermService.getRoles();
fs.readdir('api', (err, controllerDirs) => { // iterating controllers
controllerDirs.forEach((controller) => {
const controllerRoutesPath = `api/${controller}/config/routes.json`;
if (fs.existsSync(controllerRoutesPath)) {
const controllerRoutes = require(`../../${controllerRoutesPath}`)['routes']; // getting the route config
controllerRoutes.forEach((controllerRoute) => { // using the special "roles" & "enabled" fields
if (controllerRoute['roles']) {
const action = controllerRoute['handler'].substring(controllerRoute['handler'].indexOf('.') + 1);
controllerRoute['roles'].forEach(async (roleName) => {
const role = find(roles, { name: roleName });
// manually updating the permissions table to include those methods
await strapi.query('permission', 'users-permissions').model.updateOne(
{
action,
controller,
role: role['_id'],
type: 'application',
},
{ enabled: controllerRoute['enabled'], policy: '' },
{ upsert: true },
);
});
}
});
}
});
});
};
module.exports = async () => {
await fixPermissionsForTypescriptControllers();
};

What this does is the following:

  • Iterate over all APIs
  • Look for the roles field inside each API's routes.json, for each endpoint. Therefore, for all endpoints and all APIs you still want to use, you'll need those 2 extra custom fields:
  • { "method": "POST", ... "roles": ["Public"], "enabled": true }
  • If roles is present, it will create the corresponding entries in the database, allowing the endpoint to be queried

The roles field tells Strapi for which roleNames it should assign the permissions.

The enabled field tells Strapi whether access should be granted by default.

Important note

Note that this won’t work if you want different permissions for different roles. In that case, you’ll have to modify the implementation, either passing enabled as an array or creating a whole different type of object altogether.

I’m sure this can be done even more elegantly, but this was the solution I came up with.

(Optional) Step 4: Debugging in Typescript FTW

So, I created a server.js file in the root of my application, with the following contents:

const strapi = require('strapi');
strapi({ dir: process.cwd(), autoReload: true }).start();

Then, I added the following lines inside my package.json scripts:

"debug": "nodemon --inspect=0.0.0.0:9229 server.js",
"watch-debug": "concurrently --handle-input \"npm run watch-ts\" \"npm run debug\" "
  • npm run debug starts a debugger
  • Note that you’ll have to attach a node.js debugger on port 9229, you can easily do that with your IDE
  • Breakpoints will work in both js & ts code
  • npm run watch-debug is all I need for development: every time something changes in the ts code, it gets transpiled and since Strapi is running in autoReload mode, it will restart to reflect the changes. Beautiful!

Closing thoughts

It is good enough & working for us and I hope you can apply some of it, if not all in your current or future Typescript-powered Strapi projects.

For my project, continuing to write in ES5 would have been a dealbreaker, since we may have to wait for months, if not years, for official support (see this GitHub discussion about supporting Typescript). Until then, we’ll have such a big codebase that it would be extremely difficult to migrate.

Software & Machine Learning Engineer. I love building products that deliver value & happiness! https://constantin.al