A step-by-step guide to migrate a Node.js web app to Typescript
Tags: javascript, typescript
Published: 2022-01-15 11:38:03

TL: DR - Check out the [Q&D step-by-step list on Github](https://github.com/tq-bit/type-an-express-app/blob/master/README.md#step-2-add-ts-support-for-your-project-and-configure-the-compiler).

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](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html). 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:

https://medium.com/@trukrs/type-safe-javascript-with-jsdoc-7a2a63209b76

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.

![](https://developer.mozilla.org/en-US/docs/Learn/Server-side/Express_Nodejs/routes/mvc_express.png)

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](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`:

![](https://blog.q-bit.me/content/images/2021/12/image.png)

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

*   VSCode. It has built-in Typescript support and [IntelliSense](https://code.visualstudio.com/docs/editor/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](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](https://github.com/tq-bit/type-an-express-app). For a quick start, open up your terminal and run the following command in a directory of your choice.

```sh
# 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](https://github.com/tq-bit/type-an-express-app/commit/30b5d4eaa8c1addb263a199b9475a8a98d5a7387)

![](https://blog.q-bit.me/content/images/2021/12/image-1.png)

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.

```sh
/ 
| - 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](https://github.com/tq-bit/type-an-express-app/commit/30b5d4eaa8c1addb263a199b9475a8a98d5a7387)_

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

```sh
/ 
| - 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`:

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

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

```js
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](https://github.com/tq-bit/type-an-express-app/commit/db8ced2ea27fed5e749e4ec6ba54fb2a0166ca31)_

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

```bash
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](https://code.visualstudio.com/docs/typescript/typescript-compiling).

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.

```sh
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.

![](https://blog.q-bit.me/content/images/2021/12/image-3.png)

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](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html).

> You can use the [recommended template](https://www.npmjs.com/package/@tsconfig/recommended) 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](https://github.com/tq-bit/type-an-express-app/blob/master/README.md#step-2-add-ts-support-for-your-project-and-configure-the-compiler).

```json
{
  "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](https://github.com/tq-bit/type-an-express-app/commit/22be3bc4c50a83bcaf30a16f7b8fb060ce9a74fa)_

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](https://www.w3resource.com/javascript/introduction/ECMA-and-javascript.php) 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.

```js
// 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.

```js
// 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](https://www.typescriptlang.org/docs/handbook/typescript-tooling-in-5-minutes.html#type-annotations) and [interfaces](https://www.typescriptlang.org/docs/handbook/typescript-tooling-in-5-minutes.html#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](https://github.com/tq-bit/type-an-express-app/commit/22be3bc4c50a83bcaf30a16f7b8fb060ce9a74fa#diff-a0f35f50445560d6bb7c497f6ba9624da8ff951e97c1a48142c24c4e60236b3e)` 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:

```js
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:

```js
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

![](https://blog.q-bit.me/content/images/2021/12/image-4.png)

Click `CTRL` + `Space` to receive import suggestions

Step 4: Fix conflicting types
-----------------------------

[Link to commit](https://github.com/tq-bit/type-an-express-app/tree/Step-4-Fix-Conflicting-Types)

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:

```js
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](https://www.typescriptlang.org/docs/handbook/interfaces.html). 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:

```js
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`:

```js
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`:

```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:

```js
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:

```js
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](https://github.com/tq-bit/type-an-express-app/tree/Step-5-Add-Custom-Typing/src)

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](https://github.com/tq-bit/type-an-express-app/commits/Step-5-Add-Custom-Typing/src/%40types/index.d.ts).

*   `JokePath` (Type) => commit [ac3c0...de8](https://github.com/tq-bit/type-an-express-app/commit/ac3c00cd28eed4c6debc2dde3c1dda50b4a52de8#diff-b880b4c3d2b016a0fe9d248fde4ce65bc863b38df5c2c6200c4844b4e16490f1)
*   `AppMetadata` (Interface) => commit [a9bba...a78](https://github.com/tq-bit/type-an-express-app/commit/a9bbaa5db10ff058a33ae57c47157e58a4e38a78#diff-b880b4c3d2b016a0fe9d248fde4ce65bc863b38df5c2c6200c4844b4e16490f1)
*   `MultipleJokesResponse` (Interface)
*   `HomeViewConfig` (Interface)
*   `AboutViewConfig` (Interface)
*   `SearchViewConfig`  (Interface)

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

*   `view.router.ts` => [View possible solutions on Github](https://github.com/tq-bit/type-an-express-app/commit/a9bbaa5db10ff058a33ae57c47157e58a4e38a78#diff-a0f35f50445560d6bb7c497f6ba9624da8ff951e97c1a48142c24c4e60236b3e)
*   `jokes.client.ts` => [View possible solutions on Github](https://github.com/tq-bit/type-an-express-app/commit/a9bbaa5db10ff058a33ae57c47157e58a4e38a78#diff-4c24b235e5667db8d24f884eaa88f4d7c4f5c4cc02338cb5e7e2adb196eb5bf1)
*   `filesystem.util.ts` => [View possible solutions on Github](https://github.com/tq-bit/type-an-express-app/commit/a9bbaa5db10ff058a33ae57c47157e58a4e38a78#diff-cf0450a603682c5592e6f623425d829be275d2e05963a69ed9c3a44622b2bbdc)

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](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html) 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](https://www.typescriptlang.org/docs/handbook/integrating-with-build-tools.html#webpack). 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](https://github.com/tq-bit/type-an-express-app/tree/master#step-6-optional-recommended-add-typescript-to-your-dev-pipeline).