DEV Community

loading...
Cover image for Migrate to Typescript on Node.js

Migrate to Typescript on Node.js

llldar profile image Nathaniel ・7 min read

Recently I've migrated one of my personal projects from Javascript to Typescript.

The reason for migrating will not be covered here, since it's more of a personal choice.

This guide is for those who know something about Javascript but not much about Typescript and are mainly focus on Node.js applications.

Let's get right into it!

Add tsconfig.json

In order for Typescript to work, the first thing you need is a tsconfig.json

It tells the Typescript compiler on how to process you Typescript code and how to compile them into Javascript.

my config look like this:

{
  "compilerOptions": {
    "sourceMap": true,
    "esModuleInterop": true,
    "allowJs": true,
    "noImplicitAny": true,
    "moduleResolution": "node",
    "lib": ["es2018"],
    "module": "commonjs",
    "target": "es2018",
    "baseUrl": ".",
    "paths": {
      "*": ["node_modules/*", "src/types/*"]
    },
    "typeRoots": ["node_modules/@types", "src/types"],
    "outDir": "./built"
  },
  "include": ["./src/**/*", "jest.config.js"],
  "exclude": ["node_modules"]
}

Enter fullscreen mode Exit fullscreen mode

now let me explain what each line means:

  • sourceMap Whether or not typescript generate sourceMap files. since sourceMap files help map the generated js file to the ts file, it's recommended to leave this on because it helps debugging.
  • esModuleInterop Support the libraries that uses commonjs style import exports by generating __importDefault and __importStar functions.
  • allowJs Allow you to use .js files in your typescript project, great for the beginning of the migration. Once it's done I'd suggest you turn this off.
  • noImplicitAny Disallow implicit use of any, this allow us to check the types more throughly. If you feel like using any you can always add it where you use them.
  • moduleResolution Since we are on Node.js here, definitly use node.
  • lib The libs Typescript would use when compiling, usually determined by the target, since we use Node.js here, there's not really any browser compatibility concerns, so theoretically you can set it to esnext for maximum features, but it all depend on the version of you Node.js and what you team perfer.
  • module Module style of generated Js, since we use Node here, commonjs is the choice
  • target Target version of generated Js. Set it to the max version if you can just like lib
  • baseUrl Base directory, . for current directory.
  • paths When importing modules, the paths to look at when matching the key. For example you can use "@types": ["src/types"] so that you do not have to type "../../../../src/types" when trying to import something deep.
  • typeRoots Directories for your type definitions, node_modules/@types is for a popular lib named DefinitelyTyped. It includes all the d.ts files that add types for most of the popular Js libraries.
  • outDir The output directory of the generated Js files.
  • include Files to include when compiling.
  • exclude Files to exclude when compiling.

Restructure the files

Typically you have a node.js project structure like this:

projectRoot
├── folder1
│   ├── file1.js
│   └── file2.js
├── folder2
│   ├── file3.js
│   └── file4.js
├── file5.js
├── config1.js
├── config2.json
└── package.json
Enter fullscreen mode Exit fullscreen mode

With typescript, the structure need to be changed to something like this:

projectRoot
├── src
│   ├── folder1
│   │   └── file1.js
│   │   └── file2.js
│   ├── folder2
│   │   └── file3.js
│   │   └── file4.js
│   └── file5.js
├── config1.js
├── config2.json
├── package.json
├── tsconfig.json
└── built
Enter fullscreen mode Exit fullscreen mode

The reason for this change is that typescript need a folder for generated Js and a way to determine where the typescript code are. It is especially important when you have allowJs on.

The folder names does not have to be src and built , just remember to name them correspondingly to the ones you specified in tsconfig.json.

Install the types

Now after you've done the above, time to install the Typescript and the types for you libraries.

yarn global add typescript
Enter fullscreen mode Exit fullscreen mode

or

npm install -g typescript
Enter fullscreen mode Exit fullscreen mode

Also for each of your third party libs:

yarn add @types/lib1 @types/lib2 --dev
Enter fullscreen mode Exit fullscreen mode

or

npm install @types/lib1 @types/lib2 --save-dev
Enter fullscreen mode Exit fullscreen mode

Setup the tools

ESlint

The aslant config you use for Js need to be changed now.

Here's mine:

{
  "env": {
    "es6": true,
    "node": true
  },
  "extends": [
    "airbnb-typescript/base",
    "plugin:@typescript-eslint/recommended",
    "prettier/@typescript-eslint",
    "plugin:prettier/recommended",
    "plugin:jest/recommended"
  ],
  "globals": {
    "Atomics": "readonly",
    "SharedArrayBuffer": "readonly"
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "rules": {
    "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }]
  }
}

Enter fullscreen mode Exit fullscreen mode

I use ESlint with Prettier and jest. I also use airbnb's eslint config on js and I'd like to keep using them on typescript.

You need to install the new plugins by:

yarn add @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-airbnb-typescript --dev
Enter fullscreen mode Exit fullscreen mode

or

npm install @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-airbnb-typescript --save-dev
Enter fullscreen mode Exit fullscreen mode

Remember to change your eslint parser to @typescript-eslint/parser so that it can parse typescript.

nodemon

Nodemon is a great tool when you need to save changes and auto restart your program.

For typescript I recommend a new tool ts-node-dev. Because configuring the nodemon is a lot harder, while the ts-node-dev works right out of the box with zero configuration. They basically do the same thing anyway.

yarn add ts-node-dev ts-node --dev
Enter fullscreen mode Exit fullscreen mode

or

npm install ts-node-dev ts-node --save-dev
Enter fullscreen mode Exit fullscreen mode

Jest

I use jest for testing, the config need to adjust to Typescript as well

module.exports = {
  globals: {
    'ts-jest': {
      tsconfig: 'tsconfig.json'
    }
  },
  moduleFileExtensions: ['ts', 'js'],
  transform: {
    '^.+\\.(ts)$': 'ts-jest'
  },
  testEnvironment: 'node'
};

Enter fullscreen mode Exit fullscreen mode

Apparently you need ts-jest

yarn add ts-jest --dev
Enter fullscreen mode Exit fullscreen mode

or

npm install ts-jest --save-dev
Enter fullscreen mode Exit fullscreen mode

Then add ts in moduleFileExtensions, since my application is a backend only application, I didn't add jsx or tsx here, you can add them if you need to use react.

Also you need to add

globals: {
  'ts-jest': {
    tsconfig: 'tsconfig.json'
  }
}
Enter fullscreen mode Exit fullscreen mode

to let Jest know what's you Typescript config.

Package.json scripts

The scripts section in your package.json no longer works now, you need to update them:

"scripts": {
  "start": "npm run dev",
  "test": "jest",
  "build": "tsc",
  "lint": "eslint . & echo 'lint complete'",
  "dev": "ts-node-dev --respawn --transpileOnly ./src/app.ts",
  "prod": "tsc && node ./built/src/app.js",
  "debug": "tsc && node --inspect ./built/src/app.js"
},
Enter fullscreen mode Exit fullscreen mode

The commands are mostly self explanatory, just remember to customise them according to your setup.

Then you can start your program by yarn dev or npm start later. But right now the js files haven't been changed yet.

The ignore files

Remember to add built folder in your ignore files like .gitignore and .eslintignore so that they do not generate a ton of errors.

Change the code

Now that we've setup all the things. It's time that we actually change the code itself.

Typescript was built with Javascript in mind, this means you do not have to change most of you code. But you certainly going to spend quite some time changing it.

Rename the files into .ts

Rename all your .js files into .ts , except the config files.

The imports and exports

Typescript adopts the es6 import and export syntax, this means you need to change the existing commonjs const a = require('b') and module.exports = c to import a from 'b' and exports default c

See the import and export guide on MDN to have a better understanding on how to use them.

Object property assignment

You may have code like

let a = {};
a.property1 = 'abc';
a.property2 = 123;
Enter fullscreen mode Exit fullscreen mode

It's not legal in Typescript, you need to change it into something like:

let a = {
    property1: 'abc',
    property2: 123
}
Enter fullscreen mode Exit fullscreen mode

But if you have to maintain the original structure for some reason like the property might be dynamic, then use:

let a = {} as any;
a.property1 = 'abc';
a.property2 = 123;
Enter fullscreen mode Exit fullscreen mode

Add type annotations

General functions

If you have a function like this:

const f = (arg1, arg2) => {
    return arg1 + arg2;
}
Enter fullscreen mode Exit fullscreen mode

And they are intended only for number, then you can change it into:

const f = (arg1: number, arg2: number): number => {
    return arg1 + arg2;
}
Enter fullscreen mode Exit fullscreen mode

This way it cannot be used on string or any other type

Express

If you use express, then you must have some middleware function like:

(req, res, next) => {
  if (req.user) {
    next();
  } else {
    res.send('fail');
  }
})
Enter fullscreen mode Exit fullscreen mode

Now you need that req and res to be typed

import { Request, Response, NextFunction } from 'express';
Enter fullscreen mode Exit fullscreen mode

and then change

(req: Request, res: Response, next: NextFunction) => {
  if (req.user) {
    next();
  } else {
    res.send('fail');
  }
})
Enter fullscreen mode Exit fullscreen mode
mongoose

Using Typescript, you want your mongoose model to have a corresponding typescript interface with it.

Suppose you have a mongoose model that goes:

import mongoose, { Schema, model } from 'mongoose';

export const exampleSchema = new Schema(
  {
    name: {
      required: true,
      type: String
    },
    quantity: {
      type: Number
    },
    icon: { type: Schema.Types.ObjectId, ref: 'Image' }
  },
  { timestamps: true, collection: 'Example' }
);

export default model('Example', exampleSchema);
Enter fullscreen mode Exit fullscreen mode

You need add the according Typescript interface like:

export interface exampleInterface extends mongoose.Document {
  name: string;
  quantity: number;
  icon: Schema.Types.ObjectId;
}
Enter fullscreen mode Exit fullscreen mode

Also change the export into:

export default model<exampleInterface>('Example', exampleSchema);
Enter fullscreen mode Exit fullscreen mode
Extend built-in Types

Sometimes you need some custom property on the built-in type, so you need to extend them.

For example, In express, you have req.user as the type Express.User, but if your user will surely different from the default one. Here's how I did it:

import { UserInterface } from '../path/to/yourOwnUserDefinition';

declare module 'express-serve-static-core' {
  interface Request {
    user?: UserInterface;
  }
  interface Response {
    user?: UserInterface;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is called Declaration Merging in Typescript. You can read the official explanation if you want to know more about it.

Note you should name the file with extension of .d.ts and put it in a separate folder and add that folder into the typeRoots in tsconfig.json for it to work globally.

Async functions

For async functions, remember to wrap you return type with Promise<> ,

Dynamic property

If your object have a dynamic property, you need something special union type annotation for it to work.

let a : string;
if (someCondition) {
  a = 'name';
} else {
  a = 'type';
}
const b = { name: 'something', type: 'sometype' };
const c = b[a]; // gets error: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ name: string; }'.
Enter fullscreen mode Exit fullscreen mode

The way to fix it:

let a: 'name' | 'type';
if (someCondition) {
  a = 'name';
} else {
  a = 'type';
}
const b = { name: 'something', type: 'sometype' };
const c = b[a];
Enter fullscreen mode Exit fullscreen mode

Or change the last assignment into const c = b[a as 'name' | 'type'] , but apparently the first one is preferred since it checks if any unexpected value being assigned to the variable. Use this if you do not have control over the definition of the variable.

Sum up

Typescript helps a lot if you have experience in strongly typed language like C++/Java/C#, it checks many of the error at compile time. If you plan on writing an application at scale, I definitely recommend choose Typescript over Javascript.

Discussion

pic
Editor guide
Collapse
karataev profile image
Eugene Karataev

Thanks for the great article!

Typescript adopts the es6 import and export syntax, this means you need to change the existing commonjs const a = require('b') and module.exports = c to import a from 'b' and exports default c

I have a relatively big Node project written in JS which I want to convert to TS. Is it necessary to replace all require with import? Is it possible to don't touch require and just add some config property in tsconfig.json or something?

Collapse
llldar profile image
Nathaniel Author

You can find and replace your const module = require("module") with import module = require("module"), which also works with typescript.
Also there is a vs-code plugin to help you:
marketplace.visualstudio.com/items...

Collapse
karataev profile image
Collapse
masihjahangiri profile image
Masih Jahangiri

Very good article.
after i run node .\built\app.js return this error:

Object.defineProperty(exports, "__esModule", { value: true });
                      ^

ReferenceError: exports is not defined
    at file:///E:/Projects/dashboard-ts-server/built/app.js:2:23
    at ModuleJob.run (internal/modules/esm/module_job.js:110:37)
    at async Loader.import (internal/modules/esm/loader.js:167:24)
Enter fullscreen mode Exit fullscreen mode
Collapse
llldar profile image
Nathaniel Author

What is your node version? If your nodejs version is old you might want to change lib and target in tsconfig.json to es5 or older

Collapse
masihjahangiri profile image
Masih Jahangiri

I solved the issue by removing "type": "module" from package.json.