Convert an Express NodeJS App From JavaScript to TypeScript

Node TypeScript

Hi! 🖖

Today, I'll walk us through moving an Express NodeJS app from JavaScript to TypeScript.

Why? TypeScript offers type safety "on demand", most of the code won't break if you move your app from one to the other, and then, you can add the safety where it is important.

How

We are going to start from a fork of Kent C. Dodds' Express example for mid-large apps. I made a branch called javascript as a starter.

Nothing is lost, nothing is created, everything is transformed

Let's change the extension of all our app's js files to ts:

$ find . -type f -name '*.js' | grep -v node_modules | grep -v babelrc | while read line; do name=$(echo $line | sed 's/\.js$/.ts/'); mv $line $name; done

We find all js files, ignore node_modules and babelrc, and rename them to ts.

Adding TypeScript

  1. Let's add the dependencies
$ yarn add typescript --dev
$ yarn add concurrently @types/express --dev

And in package.json, we add more scripts:

"scripts": {
    "start": "node .",
    "build": "babel --delete-dir-on-start --out-dir dist --copy-files --ignore \"**/__tests__/**,**/__mocks__/**\" --no-copy-ignored src",
    "start:dev": "nodemon dist/index.js",
    "build:dev": "tsc --watch --preserveWatchOutput",
    "dev": "concurrently \"npm:build:dev\" \"npm:start:dev\""
  },
  1. Init the config
$ yarn tsc --init

You can copy my tsconfig.json, I mainly added an output dire and small things like that.

  1. Run the TypeScript compiler, crash and burn
$ yarn tsc

So, this breaks. Now let's fix the issues

Fixing a File

Let's start with a small file: src/index.ts. It returns an error that seems straightforward, but is representative of how TypeScript can be annoying with little things.

Here is the content of the file:

import logger from 'loglevel'
import {startServer} from './start'

const isTest = process.env.NODE_ENV === 'test'
const logLevel = process.env.LOG_LEVEL || (isTest ? 'warn' : 'info')

logger.setLevel(logLevel)

startServer()

And the error:

src/index.ts:7:17 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'LogLevelDesc'.

So here, we can see that logger.setLevel() is used to set the log level, taking a logLevel variable. And it is going to be a string from the LOG_LEVEL environment variable if defined, else based on the NODE_ENV variable, it will be a string: 'warn' or 'info'.

HOWEVER, this crashes now, because in TypeScript, setLevel() takes a LogLevelDesc type, which is essentially an integer with a fancy type name.

Common libraries have types well documented, toplevel not really. So I had to look at examples in the node_modules:

$ grep -rHin setlevel node_modules | less

node_modules/loglevel/test/node-integration.js:11:
log.setLevel(log.levels.TRACE);
node_modules/loglevel/test/node-integration.js:12:
log.setLevel(log.levels.DEBUG);
node_modules/loglevel/test/node-integration.js:13:
log.setLevel(log.levels.INFO);
node_modules/loglevel/test/node-integration.js:14:
log.setLevel(log.levels.WARN);
node_modules/loglevel/test/node-integration.js:15:
log.setLevel(log.levels.ERROR);

... So here we have some usage, for us it is going to be logger.levels.INFO, etc, so we replace "warn" and "info" in const logLevel = process.env.LOG_LEVEL || (isTest ? 'warn' : 'info') by logger.levels.WARN and logger.levels.INFO

It's still not enough, because process.env.LOG_LEVEL is still potentially there, and it's going to be a string. So I had to write a function to convert the string and cast it in a LogLevelDesc:

const convertLogLevel: (logLevel: string | undefined) => logger.LogLevelDesc = (
  logLevel: string | undefined,
) => {
  switch (logLevel) {
    case "1":
    case "error":
      return logger.levels.ERROR;
    case "2":
    case "warn":
      return logger.levels.WARN;
    default:
      return logger.levels.INFO;
  }
};

const isTest = process.env.NODE_ENV === "test";
const logLevel: logger.LogLevelDesc = convertLogLevel(process.env.LOG_LEVEL) ||
  (isTest ? logger.levels.WARN : logger.levels.INFO);

As you can see in the first line, I had to specifically write the type of the function (logLevel: string | undefined) => logger.LogLevelDesc (a function signature is (param1: type, param2: type, ...) => returnType).

I strongly recommend that you use a linter for your editor, so you can see type errors while writing the code.

Now that this file is fixed, let's try another one with Express code so we see how this works for bigger, better documented libraries,

Fixing an Express Route File

Now let's fix src/routes/math.ts. There is a problem with implicit any type for req, res, etc. This can be solved by defining an explicit type any for those:

async function add(req: any, res: any) {}

Types for request and stuff aren't safe and more of adding another headache than a solution. I prefer creating a type for the query parameters, this is more useful.

type MathQuery = {
  a: number;
  b: number;
  c: number;
};

async function add(req: any, res: any) {
  const mathQuery = req.query as MathQuery;
  const sum = mathQuery.a + mathQuery.c;
  res.send(sum.toString());
}

So here, we cast req.query as MathQuery, so it's not going to accept other query parameters, and it will know that they are numbers, so we don't need to convert them to numbers anymore. Cool, right?

Some Battles You Can't Win

We've seen well done transition to TypeScript, this latest compromise, now we'll see a case where it is too painful to fix the code so we ignore it.

I am a partisan of using TypeScript when it is useful, and try to use the type system the most possible, to avoid errors at runtime.

That said, there are times when it is just too exhausting, painful and a waste of time to use. Here for example, the src/start.ts file is a good example. Kent has wrapped the startServer and middleware functions in promises with no type, no real return, just a resolution. And I'm sure he knows what he's doing a lot better than me.

There is no way to match this signature without overriding or modifying the node type definitions, so in that case when we know it's working, it's faster and probably best just to ignore the type verification.

Simply add // @ts-nocheck at the top of the file.

We've done it again! 🎉

The final code