Back

A step-by-step guide to migrate a Node.js web app to Typescript

TL: DR - Check out the Q&D step-by-step list on Github.

In a previous article, I described my first debugging session that could have been prevented by typing. In an attempt to see it as an opportunity, I wanted to try and migrate an application myself.

Before you read on, I'd like to say that this approach is opinionated. It follows a few best practices, such as the ones described in the official Typescript Migration Guide. For big projects, you will probably need a different strategy. Perhaps to incrementally adapt types or change only a few components at once. In some cases, adding JSDoc typing will also do the trick. In such a case, you should check out this Medium article on Type-Safe Javascript with JSDoc:

Type Safe JavaScript with JSDoc
JSDoc Comments Can Provide Powerful IntelliSense and Type Safety when used with Visual Studio Code.

With that out of the way, let's now dive into the topic at hand.

The application in a nutshell

The project this article uses is a full-stack app that fetches a random joke from a third-party API. It loosely follows MVC architecture without any custom Javascript on the frontend side.

Instead of models, this project uses services to interact with the third-party API. Source: https://developer.mozilla.org/en-US/docs/Learn/Server-side/Express_Nodejs/routes

So when starting up the application, you will see the following interface at http://localhost:3000:

It uses the usual suspects for its technology/dev stack:

  • VSCode. It has built-in Typescript support and IntelliSense.
  • Node v14+. It's required for the fs/promises - module.
  • Express.js with express-handlebars as its templating engine.
  • Axios as an HTTP client. It fetches random jokes from https://jokeapi.dev.
  • Winston for logging. It's used in custom middleware once.
  • Nodemon + ts-node to listen for changes during development.

If you would like to follow along, you can fork or clone the repository for this article from Github. For a quick start, open up your terminal and run the following command in a directory of your choice.

# Clone the repos and install necessary dependencies
git clone https://github.com/tq-bit/type-an-express-app.git
cd type-an-express-app
npm install

Each migration step is reflected by a branch. You can find the link to it under each section in this article.

As an example for Step 1:
- Step 1 will be to update the project structure
- Changes made are saved highlighted in the respective branch
- You can review them here: Link to commit
Each step has its own branch and a single commit. You can review the commit for the exact changes made to the application's code.

The initial project structure

Before starting the migration, let's briefly check out the initial folder structure.

/ 
| - middleware/ # includes a single logging middleware for access logging
| - public/     # includes a single, static image for the 404 view
| - routes/     # includes the app's routing logic
| - services/   # includes the HTTP client logic for JokeAPI
| - util/       # includes two helper modules for common usage
| - views/      # includes the .handlebars templates
| - index.js    # the entrypoint for our app

Step 1: The new project structure

Link to commit

Instead of having all directories into the project's root, we'll move them to a dedicated folder.

/ 
| - src/
|   | - middleware/
|   | - public/
|   | - routes/
|   | - services/
|   | - util/
| - views/
| - index.js

Next, we will change the file extension from  .js to .ts to enable Typescript Intellisense.

Compiling Typescript files to Javascript comes with a few perks, like functional integrity over different Node versions.

Let's adjust the dependency paths and the npm scripts. For this project, we'll need to make two adjustments:

1. Change the dev script in package.json:

// ...
  "main": "./src/index.ts",
  "scripts": {
    "dev": "nodemon src/index.ts"
  },
// ...

2. Adjust the path inside filesystem.util.ts:

async function readPackageJsonFile() {
  const jsonBuffer = await fs.readFile(path.join(__dirname, '../../package.json'));
  const jsonString = Buffer.from(jsonBuffer).toString('utf-8')
  return JSON.parse(jsonString);
}

When migrating on your own, you must make sure all other paths in your project resolve properly.

Step 2: Add TS support and configure the compiler

Link to commit

The Node runtime (currently) ships without a built-in Typescript compiler. To handle .ts files, we must install a few dependencies. Let's start by installing the compiler itself.

If you would like to deploy and build the project later, you should also add typescript as a dev - dependency
npm i -g typescript
# npm i -D typescript

Installing typescript globally gives us access to the tsc command. It exposes a variety of methods to check, assemble and test .ts files. For the scope of this article, we won't cover its functionality in detail. You can learn more about it in the official docs.

Compiling every time after making a change seems clumsy. Fortunately, there is a node module to the rescue.

ts-node also works well with the latest version of nodemon.

While we are at it, let's also install the types for express, express-handlebars and node itself.

npm i -D ts-node @types/node @types/express @types/express-handlebars

In case you wonder: @types refers to a repository for open Typescript definitions. The availability of types for a node module is indicated by the small DT banner next to its name.

If NPM shows this indicator next to the module name, you can install the package's types by running npm install -D @types/<module_name>

We are now able to compile, run and type our project. Let's wrap this step up by creating a tsconfig.json file. It will hold the configuration options for the compiler and can be adjusted to your project's needs. To learn more about this config file, check out the official docs.

You can use the recommended template or third party solutions if you don't want to specify a config file yourself.

In your project's root directory, add a file called tsconfig.json with the following content. You can find a short explanation and references to what each option does in the repos for this app.

{
  "compilerOptions": {
    "target": "ES2015",
    "outDir": "dist",
    "module": "commonjs",
    "moduleResolution": "node",
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Recommended",
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

We're done setting up our dev environment. You are probably tempted to give it a shot and run npm run dev. Bear with me though, the app will error out for a couple of reasons. Let's have a look at them.

Step 3: Apply Typescript syntax

Link to commit

We're now making the first big step in our migration experiment. Typescript's primary purpose is to provide us with static types. But there's more to it. Since there is a compilation step between .ts and .js files, we can use modern ECMA concepts without making a compromise in functional integrity between browsers.

Convert CommonJS to ES6 module syntax

Instead of using CommonJS, I would like to employ the more modern ES6 module syntax. It allows me to import types alongside modules. Let's incorporate the new syntax for each file like this:

  • Replace const ... = require(...) with import ... from ... when importing modules.
// const express = require('express'); // before
import express from 'express';         // after

// With ES6 syntax, we can also import types. This will come in handy soon
import { Request, Response } from 'express'
  • Replace module.exports with export or export default when exporting classes, functions, objects, or variables.
// module.exports = logger; // before
export default logger;      // after

Import & apply third party types

In step two, we have installed types for express and express-handlebars. Let's add them to our codebase.

Types and interfaces provide shape to data. They narrow down - or predefine - what variables must look like. They are also used to define the input (arguments) and output (return value) of functions.

Having that in mind, let's take a look at our view.router.ts file.

When converting to ES6 import syntax, you probably noticed that calling a function on an import does not work as you would expect it with Commonjs.

import router from 'express'.Router() is no valid TS/JS syntax

You will also note that we currently have a few problems with the route handlers.

Parameter 'req' implicitly has an 'any' type

Let's assume the first few lines of your router file currently look like this:

import router from 'express'.Router() // <- this is no valid syntax!
import readPackageJsonFile from '../util/filesystem.util';
import { getRandomJoke, searchJokes } from '../services/jokes.client';

async function renderHomePage(req, res) { // <- function arguments are not types (yet)
  const packageJson = await readPackageJsonFile();
  const randomJoke = await getRandomJoke();
  const homeConfig = { packageJson, randomJoke };
  res.render('home', homeConfig);
}

We can now use Typescript's syntax to import Router. It will be available to us as a type and as a function. We can also import the Request and Response types to apply them to the function's arguments:

import { Router, Request, Response } from 'express' 
// ...

async function renderHomePage(req: Request, res: Response) {
  // ...
}

Try to now do the same thing in the accesslog.middleware.ts file yourself. Also, try and guess the type of Express' next function.

Try and press CTRL + Space after clicking inside the import { ... } curly braces. VSCode will then provide you with a list of suggested functions and types
Click CTRL + Space to receive import suggestions

Step 4: Fix conflicting types

Link to commit

Pacifying the TS compiler will take more than just third-party types. Let's stay in our router file a moment longer and take a look at the following function:

async function renderSearchPage(req: Request, res: Response) {
  const hasSearchRequest = Object.keys(req.query).length > 0;
  const packageJson = await readPackageJsonFile();
  let searchConfig = { packageJson };
  if (hasSearchRequest) {
    const searchResults = await searchJokes(req.query); // <- TS error
    searchConfig = { ...searchConfig, searchResults };  // <- TS error
  }
  res.render('search', searchConfig);
}

Inside the if clause, we're checking if the user was searching for a particular joke. Should this be the case, the results will be passed into the .hbs template for rendering.  You will notice that searchJokes expects an object with four properties and req.query does not satisfy this assertion.

Also, searchConfig's type is automatically assigned when the object is created. Since we want to inject the search results conditionally, we must think of a way around it.

Create a custom interface for the joke query

One way to solve the first matter is to define an Interface. Using interfaces, we can make assumptions about how data is shaped. In this case, the shape of the argument passed into searchJokes.

While it is possible to declare an interface in the router file, we will use a dedicated directory. So go ahead and create a folder called @types in your project's source. Then, create a new file called index.d.ts in it.

Once you've done that, let's add the following interface declaration:

export interface JokeQuery {
  search: string;
  all: string;
  nsfw: string;
  count: string;
}

Like with the express types, we can now import and apply this interface in view.router.ts and jokes.client.ts.

In the view.router.ts:

import { JokeQuery } from '../@types/index';

// ...
if (hasSearchRequest) {
    const jokeQuery: JokeQuery = {
      search: `${req.query.search}`,
      all: `${req.query.all}`,
      nsfw: `${req.query.nsfw}`,
      count: `${req.query.count}`,
    };
    const searchResults = await searchJokes(jokeQuery);
    searchConfig = { ...searchConfig, searchResults };
  }
// ...

In the jokes.client.ts:

import { JokeQuery } from '../@types/index';

// ...

export async function searchJokes({ search, all, nsfw, count }: JokeQuery) { 
  // ... 
}

Create a custom interface for the search config

The same principle can be applied to solve our second problem. Remember that searchConfig's type is inferred when the object is defined. We can again use an interface to declare the shape of searchConfig beforehand.

Add the following to your @types/index.d.ts file:

export interface SearchViewConfig {
  packageJson: {
    version: string;
    description: string;
    author: string;
    license: string;
    packages: string[];
  };
  searchResults?: {
    amount: number;
    jokes: {
      category: string;
      type: string;
      setup: string;
      delivery: string;
      error?: boolean;
      message?: string;
    }[];
    error: boolean;
    message?: string;
  };
}
The questionmark behind searchResults declares the property as optional

Importing and adding this interface to view.router.ts will finally resolve the issue of the conflicting types:

import { SearchViewConfig, JokeQuery } from '../@types/index';

// ...

async function renderSearchPage(req: Request, res: Response) {
  // ...
  let searchConfig: SearchViewConfig = { packageJson };
  // ...
}

Step 5: Add custom types

Link to commit

In the previous step, we've already gone to the core of what Typescript does for us. It provides a way to give shape to data in our code.

Adding custom types is a tedious task. But it adds a lot of value to your codebase. And a good time to put your new knowledge to practice.

If you haven't done it yet, clone the repos to your local machine and try to walk through the steps below. If you get stuck, take a look into the file history - I will link for each change I made. Try and come up with your own solution though.

  1. Add these types and interfaces to @types/index.d.ts.
    You can find the whole solution on Github.
  • JokePath (Type) => commit ac3c0...de8
  • AppMetadata (Interface) => commit a9bba...a78
  • MultipleJokesResponse (Interface)
  • HomeViewConfig (Interface)
  • AboutViewConfig (Interface)
  • SearchViewConfig  (Interface)

2. Then, apply the types to the following files:

3. (Optional) Declare inferred types

For example:

  • Replace const HOST = '0.0.0.0' with const HOST: string = '0.0.0.0'
  • Replace const app = express() with const app: express.Application = express()

This step is not mandatory. But it helped me to understand how exported modules are connected to their type declarations.

Let's recap

We have made a lot of changes:

  • We migrated our whole codebase.
  • We added third-party types.
  • We extended the app with our own types.

There are plenty of other TS - features to consider when typing your projects. If you would like to get more familiar with Typescript, you might want to take a look at the official docs and tutorials. But there was another thing that tickled the back of my head.

What next?

I'm talking about integrating TS into my development workflow. Typescript comes with the cost of compilation. Assuming we're using ts-node, this problem is handled for us during development. But this might not apply to a productive app.

I found some examples in the official documentation. Unfortunately, they feature only an isolated compilation example. If you are familiar with task runners such as Gulp, you'll know that doing only a single thing is rarely what you want.

As a small bonus (and to say thank you for lasting through this whole article), I have added two additional steps that illustrate how I built this sample project. The resulting application can be executed by any Node v14+ environment without using ts-node.

You can check these steps out in the repository's Readme file, Step 6 and Step 7.


Read more about web_technology