Migrating your Node.js Express API to TypeScript

Migrating your Node.js Express API to TypeScript

Linus's photo
Β·Jan 14, 2021Β·

13 min read

Subscribe to my newsletter and never miss my upcoming articles


Recently, I made the impactful decision to migrate my entire Node.js API from JavaScript to TypeScript. I worked with other statically typed programming languages before and therefore understood the basic concept of TypeScript. Nonetheless, I experienced some difficulties getting started with the migration, especially in terms of mongoose & express. In this post, I'll include (hopefully πŸ˜…) everything needed to migrate your API over to TypeScript. Additionally, I'll briefly cover TypeScript's advantages and migration strategies.


  • basic knowledge of TypeScript's syntax; I encourage you to check out the 5 minute tutorial for JS Developers before reading on
  • some exposure to statically typed programming languages

Enjoy your read 😊


Migration Strategies

I had quite a hard time deciding whether or not I should convert my API to TypeScript. Especially when there's a lot of logic that needs to be migrated. There's always the question of Effort vs. Benefit, and there's no general answer in terms of which path you should take. It all depends on your project's particular needs. If you're not sure about migrating, I believe the comment section of my reddit post very helpful.

However, the bigger your project and especially if you're planning on scaling your application at some point in time, TypeScript will definitely make your life easier.

In terms of how to migrate, there are basically two options:

  • migrate everything at once, "short & sweet": all .js files are converted to .ts files, with the ultimate goal of your code being fully typed, i.e. no implicit any types are used
  • migrate over time, "incremental change": all JS is valid TS; therefore, you could start out slowly and migrate only those files that contain complex logic; new code is written in TS.

Both options are valid. If your project is rather small though or you're not forced to ship features, I recommend migrating everything at once. Thereby, start with the most "basic" files, i.e. those files that don't require any other files.

Otherwise, definitely choose the second approach. If your resources are required for developing features, there's no point in migrating everything at once. Add those features in TypeScript and start migrating the rest of your project once you have the required resources to do so.

Let's have a look at some of TypeScript's benefits.


Advantages & Drawbacks of TypeScript

One of TypeScript's greatest benefits are its types (duhhh). Most of the small errors that sneak in while writing code are detected. As a JS developer, you're probably familiar with the following error:

TypeError: β€˜undefined’ is not a function

With TypeScript, those errors aren't detected at runtime; they prevent your code from being compiled and are therefore easy to spot. If you're using VS Code, they're even highlighted as you write.

Additionally, IntelliSense is far better due to the typed nature of TypeScript, which is especially helpful if you're working on larger projects.

In a nutshell, TypeScript is more explicit and thereby far easier to maintain. Your changes are less likely to brick your entire application... #beentheredonethat πŸ‘€

If you're reading this article, you can probably imagine one of TypeScript's biggest drawbacks: Although there are benefits in the long run, migration takes time and work.

Additionally, in the short run, declaring all the types may seem a little extra sometimes.


1 - Preparing your project for migration

First of all, if you haven't already done so, install the typescript package and add it to your project:

> npm install -g typescript
> npm install --save-dev typescript

Now, it's time to create the tsconfig.json file:

> tsc --init

If you now open this file, there's a lot of stuff commented out. Feel free to uncomment the options you need. I didn't change too much in my tsconfig.json file though:

  "compilerOptions": {
    "target": "es6", // we don't write frontend code, so es6 is fine
    "module": "commonjs",
    "allowJs": true, // true = not all files have to be .ts files
    "outDir": "./build", // the folder in which the compiled JS files are put
    "strict": true, // enables all strict type-checking options
    "alwaysStrict": true, // 'use strict' at the beginning of compiled .js files
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true

The comments provided alongside these options are quite extensive which is why I won't include any explanation here. However, more information on tsconfig.json can be found here.


2 - Installing types for your modules

In order for TypeScript to recognize your modules' types, you need to install some type definitions. You can install them like so:

> npm install @types/<library name> --save-dev

We're using the --save-dev option as we only need those type definitions during development to compile our TypeScript code; the code that is executed is pure JavaScript code and couldn't care less about any type definitions.

You can either install types for all modules right away or wait for Visual Studio Code's linter to suggest it once you import a module:

import mongoose from "mongoose" // warning appears if module types are missing

That's it, your project is ready for the migration 😊 Now, you can start changing your file extensions from .js to .ts.

To compile your files, simply run:

> tsc

You'll find the compiled files in whatever location you specified for outDir inside the tsconfig.json file.

What follows are specific explanations of how to use mongoose & express the TypeScript way. Other than that, migration is really easy with some background knowledge on TypeScript.

3 - Imports

Right now, you've probably imported your modules like so:

var mongoose = require("mongoose")

Migrating your code to TypeScript, however, your imports should look a bit different:

1. default imports

import mongoose from "mongoose"

2. import something specific

import { Document } from "mongoose"

3. import everything

import * as mongoose from "mongoose"


4 - TS for your mongoose schemas πŸŽ‰

Assuming we have the following schema declaration:

const MySubCollectionSchema = new mongoose.Schema({
    subValue1: { type: String, required: true },
    subValue2: { type: Number, required: true }

const AccountSchema = new mongoose.Schema({
    name: { type: String, required: true },
    orderCount: { type: Number, required: false}
    subCollection: { type: [MySubCollectionSchema], required: true }

AccountSchema.statics.addFunction = function(a: number, b: number): number {
    return a + b;

In order to use our schemas the TypeScript way (and make use of its statically typed nature), we'll create interfaces:

export interface IAccount extends mongoose.Document {
    name: string,
    orderCount?: number,
    subCollection: mongoose.Types.DocumentArray<IMySubCollection>

export interface IAccountModel extends mongoose.Model<IAccount> {
    addFunction(a: number, b: number): number

interface IMySubCollection {
    subValue1: string,
    subValue2: number

Interfaces provide TypeScript with information about the shape that values (e.g. Objects) have. The following explanation should make this more clear.

In our case, we're declaring that every document conforming to IAccount contains certain fields. Notice the ? which is used to make a field optional. For IAccount, only name and subCollection are required. orderCount is optional; if it is provided though, it has to be of type number.

As we're extending mongoose.Document, we're making use of mongoose's Document interface, i.e. we still have access to document properties and functions like .save(), we simply add a few fields/functions to it.

Woah. Breathe. That's quite some information.

We're also extending mongoose.Model<IAccount> to add information about the static method addFunction to our account model (therefore <IAccount>). Without doing so, TypeScript doesn't know about any static methods you define on your schema.

The same goes for methods & virtuals, except that you declare them directly in your schema interface, i.e. IAccount in the above example.

Remember: statics are defined on a mongoose model, whereas methods & virtuals are defined on mongoose documents.

In order for our Model & Documents to actually implement our interfaces, we have to model them correctly:

mongoose.model<IAccount, IAccountModel>("accounts", AccountSchema);

That's it! Now you get full IntelliSense as TypeScript knows what an Account document looks like as well as what functions are defined on the Account model.


5 - TS for your express Request

If you're using express, there's a high chance you attach data to the express' request object. For example:

app.use(req: express.Request, res: express.Response, next: express.NextFunction) {
    req.myCustomProp = "this is my custom prop"

Without extending express' request interface, you'll receive an error that myCustomProp doesn't exist on type express.Request. Write the following code:

export default interface IRequest extends express.Request {
  myCustomProp?: string;

Now, simply replace

app.use(req: express.Response, ...){...}


app.use(req: IRequest, ...){...}

and witness the error disappear. That's it! You can now attach data to your express request object! Attaching data to the response object works exactly the same!



Congrats, you now know how to migrate from JavaScript to TypeScript πŸŽ‚ I sincerely hope you had a nice read and learned something new. As always, if you have any questions, just comment them and I'll try my best to help you out 😊

I would love to hear your feedback! Let me know what you liked or what could've been better with this tutorial - I always welcome constructive criticism! If you have any questions, just comment them and I'll try my best to help you out :)

In love with what you just read? Follow me for more content like this πŸ‘€


Β© GIFs from Giphy

πŸ“„ TypeScript Interfaces

Share this