circuit

How to Convert Node.js Code from JavaScript to TypeScript




Photo by Tudor Baciu on Unsplash

Photo by Tudor Baciu on Unsplash

Introduction

This guide contains a lot of information on how to begin converting an existing JavaScript project to TypeScript.

Step-by-step approach…

This document begins with establishing a project (still in JavaScript) with a TypeScript “compile-no-errors” setup (retaining all files as .js). One step at a time, we'll then resolve the found issues and finally become TypeScript-only (.ts-only).

I would recommend reading this document twice. First to establish a list of all todo's, and second to actually work on them. There are so many things that are very important (“this should be at the very top of the document”), but it's not really feasible.

Some TypeScript basics

If you've never worked with TypeScript, it will be an additional layer of challenges. There's a couple of important “mentions” that needs to be done:

  • TypeScript only exists at “compile” time, at runtime it's still JavaScript

  • TypeScript doesn't really compile, it transpiles.

  • The TypeScript transpiler is named “tsc”

  • node (node.exe) can only run JavaScript code, but by adding the “plugin” ts-node, typescript support is achieved.

  • VSCode has great support for TypeScript, and so does ESLint (with the correct plugin)

Most important is probably this: Running the code and transpiling the code are two different things. You'll need to make both worlds work.

If you want to check out a working (empty) project then check out https://github.com/tomnil/emptyts.

Types…

While there are tips on resolving common TypeScript issues, there's little information here on how to actually write types. Still, a couple of tips can be important.

any

Any basically disables type checking for a variable (read: makes it “JavaScript”). any should be avoided, but is very usable in some scenarios.

let a : any = {}
a.name = "Sarah";   // TypeScript checking is disabled here

unknown

unknown and any are “remote friends”, but unknown doesn't allow accessing properties by their name.

let myBox: unknown = {};       // OK!
myBox = { foo: "bar" };        // OK!
console.log(myBox.foo);        // Error
myBox.foo = "FooBar";          // Error

Objects

Either way works:

let user1: { Name: string, Age: number };
user1 = { Name: "John", Age: 16 };
console.log(user1.Name);

type User = { Name: string, Age: number };
let user2: User = { Name: John", Age: 16 };
console.log(user2.Name);

Now, with the basic stuff out of the way, let's begin converting.

Establishing the toolchain

There's a number of tools that must to be in place to be able to work with TypeScript. There are also a number of tools that make it easier :)

tsc + typescript

Begin with making a global install of tsc(the transpiler) and typescript itself.

npm install tsc -g

npm install typescript --save-dev

ts-node

ts-node is the module that adds support for running TypeScript directly (that is; not first compiling from .ts to .js and then run .js. ts-node can run .ts directly).

This tool must be installed locally.

npm install ts-node --save-dev

typesync (optional)

A great tool for automatically downloading definition files for all your referenced modules (where it's not built-in into the module you've referenced). Install with npm.

npm install -g typesync

eslint (optional)

eslint is equally important, but not covered by this document.

Configuring your project for TypeScript

Create tsconfig.json

In the root of the project, create tsconfig.json. Begin with this content:

{
  "compilerOptions": { /* Docs: [https://www.typescriptlang.org/tsconfig](https://www.typescriptlang.org/tsconfig) */
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "bin",
    "rootDir": "src",
    "strict": true,
    "noImplicitAny": false,
    "strictNullChecks": true,
    "checkJs": true,
    "allowJs": true,
    "moduleResolution": "node",
    "types": [
      "node"
    ],
    "lib": [
      "es6"
    ],
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "allowUnreachableCode": true,
     "useUnknownInCatchVariables": false,  // TypeScript 4.4+ only!
},
  "include": [
    "src/**/*.ts",
    "src/**/*.js"
  ],
  "exclude": [
    "node_modules",
    "<node_internals>/**",
  ]
}

Change include and exclude accordingly (note, these settings will not be used by some tools unless the environment variable TS_NODE_FILES is set to true ).

Some quick notes on the config settings

“target”: “ES2020”

If you're doing backend development then it's most logical to use as new EcmaScript version as possible.

“rootDir”: “src”

If you follow most standards, your source code is in “src”

“outDir”: “out”

Typescript is build around “compiling” (really transpiling) from the /src/ folder into a destination directory /out/.

This guide avoids using the /out/ folder since it really slows down the development and debugging process. Running the code as-is (as .ts) is much more efficient. Still, the “outDir” must be set.

“allowJs” — transpiling javascript files

Turn on allowJs and checkJs if you want .js files to be treated to .ts-files. Also, make sure both .ts and .js is included in the include section.

"include": [
        "src/**/*.ts",
        "src/**/*.js"
    ],

useUnknownInCatchVariables

This is a new setting beginning with TypeScript 4.4. Per default it's set to true, which means the error in catch will be unknown , and per default it's impossible to access .message .

The easy fix is to (, for now, set useUnknownInCatchVariables to false , or to make a proper fix as. See below for more information on this topic.

.gitignore out-folder

Using git? Modify .gitignore to exclude the out folder and temporary files from tsc .

out/
# TS -incremental file
tsconfig.tsbuildinfo
node_modules

Removing the folder “out” from search results

In .vscode/settings.json, use the following settings:

{
  "search.exclude": {
     "**/node_modules": true,
     "**/package-lock.json": true,
     "**/package.json": true,
     "out/**": true // Don't search the TypeScript "out" folder
   }
}

vscode: Reload the window to ensure proper loading of tsconfig.json

vscode will process tsconfig.jsonbut it's probably a good idea to reload vscode at this time: Press ctrl-shift-p and search for the command Developer: reload window.

Add existing types

Types for modules installed from the npm registry can be resolved through four different ways:

  • The types are included from the module owner

  • The types can be installed seperately

  • Manually write the missing types

  • Set the module to be “any”

typesync is a cool tool that detects missing types (if not found, install it with npm)

> typesync

📦 projectname — package.json (1 new typings added, 0 unused typings removed)
└─ + @types/**packagename1**

✨  Go ahead and run npm install or yarn to install the packages that were added.

> npm install

If you cannot find needed types, there are only two ways left to fix it. Either you write the types yourself or you declare the entire module as any. The latter is often most easy: Create a file in the root named “declarations.d.ts” and allow it to contain just “declare module” statements (nothing else or it will stop working).

declare module 'excel4node';

If you want some information on how to write types, check out https://github.com/DefinitelyTyped/DefinitelyTyped.

Setting types on variables

As stated, this guide is not a TypeScript guide. But giving a hint is probably a good thing. There are three ways to assign a type to a variable:

type User = { Name: string, Age: number };

let a : User | undefined = undefined;
let b = <User>{ Name: "Karin", Age: 40 };   // Type Assertion
let c = { Name: "Karin", Age: 40 } as User; // Type Assertion

Transpiling and deciding how to fix errors

On the first TypeScript transpile your current javascript files are almost guranteed to generate a ton of warnings and errors. Before even attempting to switch to TypeScript, it's a most likely a must fix many of the problems in the existing javascript files. From command line, run:

tsc -b -v

… or if you prefer tsc to run incrementally and with a file watcher:

tsc --build --verbose --incremental --watch

# or shorter:

tsc -b -v -i -w

Keep the compiler running in watch mode in a window as you resolve errors one by one.

Order of fixing

If possible, start with the “deepest” level of code, ie code loading data from disk/internet. Basically, if the LoadWeatherDataFromRemoteAPI() returns { Temparature: 25 } , the function should look somehting like this:

function LoadWeatherDataFromRemoteAPI() : { Temparature: number } {
 // ... code for fetching here
}

This topic will be covered in more detail below (including how to handle Promise/await/async).

An JsDoc opportunity

The TypeScript transpiler understand JsDoc comments and vscode recognizes them, but once the conversion is complete some (much?) of the JsDoc isn't needed. TypeScript should win for the types (and optionally keep JsDoc for other stuff)

If your code totally lacks JsDoc at this point, it *might *be helpful to write/generate it as an intermediate solution. It will help you resolve errors, but it's also a step in the wrong direction. Once conversion is completed, this newly written JsDoc should probably be removed. JsDoc example:

And variables using /** @type { *thetype _} _/

Automatically writing the documentation / resolving the type

Consider the following JavaScript code. The function lacks types on the parameter:

function RandomNumber(iTo) {
    return Math.round(Math.random() * iTo + 0.5);
}

It's possible to automatcially infer the type :) But I strongly recommend to do this after the files has been renamed to .ts since it will write real typescript code (rather than jsDoc). Read more on this topic below.

Resolving errors

While keeping the files as .js, it's possible to resolve a lot of problems. Now, if you already at this point decide to rename to .ts, it's alright. Just read the section on renaming first :)

Operator 'xyz' cannot be applied to types 'string' and 'number'.

Seems like an easy one, don't mix types. :)

// Incorrect type set
let a="0";
console.log(a+1);   // Disallowed, a is a string

// toFixed converts to string!
const a = 3.1415926;
const b = a.toFixed(2);
const c = 2 * b;     // Disallowed, b is a string

fs.readFileSync and JSON.parse problems

Argument of type 'Buffer' is not assignable to parameter of type 'string'.ts(2345)

Solution:

# Change from:
let result1 = JSON.parse(fs.readFileSync(fileName));

To:
let result2 = JSON.parse(fs.readFileSync(fileName, 'utf-8'));

If nothing else, just ignore the error

Well, there might be errors that can be troublesome to fix right now. Just tag the line with @ts-ignore (and maybe add it to the backlog to fix it later):

process.on('unhandledRejection', (error) => {
    //@ts-ignore
    logger.info("Unhandled exception - internal error. Stacktrace=" + error.stack);
});

Special notes about global variables and Express

Not all projects include global variables or uses Express. This section is dedicated for problems related to that.

declare global

error TS2339: Property 'myVariable' does not exist on type 'Global & typeof globalThis'.

Solution: In a .d.ts file, define the object(s). Here's some examples to get past this problem:

var myVariable: boolean;
    var myClass1: Object;
    var myClass2: any;
    var myObject3: { Name: string, Username: string };
    var myObject3Array: { Name: string, Username: string }[];
}

Typically you'd see the below style as recommendations, but above style will work with tsc, while running the code and with intellisense.

declare global {
    namespace NodeJS {
        interface Global {
            myVariable: boolean,
            myClass1: Object,
            myClass2: any,
            myObject3: { Name: string, Username: string },
            myObject3Array: { Name: string, Username: string }[],
        }
    }
}

Either way, use the one that works best for you.

Express

Add the types Express types as (don't forget to import Express):

import Express from "express";

app.get(`/ping`, (req: Express.Request, res: Express.Response) => {
   return res.json({ result: "pong"});
});

If you expect additional properties on for for example Express.Request , you might get an error as this:

error TS2339: Property 'User' does not exist on type 'Request<ParamsDictionary>'.

Solution is to modify the global namespace (see above for another example).

declare global {
  namespace Express {
    interface Request {
      User: { Name: string }
    }
  }
}

Try to run the project as-is

If you manage do run tsc with zero errors, then there's a chance the project may run at this time. Again, *transpiling *the code and *running *the code are two different things.

From command line

We're going to avoid “compiling” (read: transpiling) into the /out/ destination directory using a build step, it's better to setup all tools to run typescript as-is.

Running the code without transpiling is easy, it's just to include ts-node in the start command:

# Faster startup
node.exe -r ts-node/register/transpile-only ./src/index.js

# Or use:
node.exe -r ts-node/register ./src/index.js

# Read more at: [https://github.com/TypeStrong/ts-node](https://github.com/TypeStrong/ts-node)

Not getting a success? Want a working project? Check out the emptyts project on github.

Debugging directly from vscode

Now, we haven't configured vscode yet. You need to make additional changes for this to work. Open launch.json and use the follwing. Modify args to match the name of your starting file.

{
 // Detailed docs:
 // [https://code.visualstudio.com/docs/nodejs/nodejs-debugging](https://code.visualstudio.com/docs/nodejs/nodejs-debugging)
 "version": "0.2.0",
 "configurations": [
  {
   "name": "Debug typescript",
   "type": "node",
   "request": "launch",
   "smartStep": false,
   "sourceMaps": true,
   "args": [
    "${workspaceRoot}/src/index.ts"
   ],
   "runtimeArgs": [
    "-r",
    "ts-node/register/transpile-only"
   ],
   "cwd": "${workspaceRoot}",
   "protocol": "inspector",
   "internalConsoleOptions": "openOnSessionStart",
   "env": {
    "TS_NODE_FILES": "true" // Respect include/exclude in tsconfig.json => will read declaration files  (ts-node --files src\index.ts)
   },
   "skipFiles": [
    "<node_internals>/*",
    "<node_internals>/**",
    "<node_internals>/**/*",
    "${workspaceRoot}/node_modules/**",
    "${workspaceRoot}/node_modules/**/*"
   ],
   "outputCapture": "std",
   "stopOnEntry": false
  }
 ]
}

Press ctrl-shift-p to bring up the command palette. Search and select for Debug: Select and start debugging. In the menu that appears, select Debug typescript (as named above).

Tip: Breakpoints is toggled by pressing F9 on a line.

Nodemon? ts-node?

If you want to run nodemon, check out the emptyts project, it's documented there.

Changing require to import, and module.exports to export

Exactly how to write import and export statements is somewhat outside of this guide, but it's a good thing to mention. Also, this might not apply to your project.

Please understand: it's not enough to fix only require, both require and exports needs to be done.

Replacing “require” with “import”.

Example:

const stack = require('callsite');
const winston = require('winston');
const path = require("path");
require('winston-daily-rotate-file')

will become:

import stack from 'callsite';
import winston from 'winston';
import * as path from 'path';
import 'winston-daily-rotate-file';

Search and replace

It's not possible to make a successful search and replace, since import works slightly different.Begin with fixing some common requires like fs and path .

Search: const fs = require("fs");
Replace: import * as fs from 'fs';

Search: const path = require("path");
Replace: import * as path from 'path';

Since require does some magic behind the scenes, and import does not: it's impossible to do make a straight search/replace from require to import. Depending on how the module is used any of the following could be used:

// Entire module
import * as xyz from 'xyz';
import xyz from 'xyz';

// Specific object
import { User } from 'xyz';

// Both
import xyz, { User} from 'xyz';

Now, this will most likely fail, but at least some manual writing can be avoided. Press ctrl-shift-h, and make sure you tick “regex”:

Search: ^\w+\s+(\w+)\s+=\s+require\((["'].*["'])\);{0,2}$
Replace: import * as $1 from $2;

Check the output of tsc and try to fix some of the obvious problems, but please be aware that it's impossible to resolve all until export has been fixed as well. So keep on reading…

Replacing “module.exports” with “export”

Example:

module.exports.Function1 = Function1;
module.exports.Function2 = Function2;
module.exports.Function3 = Function3;

// or:
module.exports = { Function1, Function2, Function3}

becomes:

export { Function1, Function2, Function3 }

# Or use export before the function keyword:

export function CreateOrder() {
    // Do work
}

# If you want a default export, use:

export default { Function1, Function2, Function3 }

Fixing “module.exports”

Well, this is hard. There are no easy way of doing search & replace on this one. A lot of manual fixing is needed. Try this:

Search: (module\.exports\s=\s)(.*)$
Replace: export default $2

Now fix the problems reported by tsc.

Possible challenges with package.json

It may help / make things worse if setting type to module .

{
	"name": "my project1",
	"version": "1.0.0",
	"private": true,
	"type": "module",
	 ...
}

Not getting a success? Want a working project? Check out the emptyts project on Github.

Renaming all files .js to .ts

Now, this is where it gets interesting. Renaming the files will disable some features and enable many others.

To rename all source files from .js to .ts, use a tool of your choice. Also change launch.json (and other places) from starting on index.js to index.ts

IMPORTANT: Restart VSCode after the rename.

After the rename, every check of TypeScript will be activated. Probably both VSCode and tscwill report new errors. Some of these can easily be resolved while others may even require to redesign the code. Still, you can ask TypeScript to fallback to old javascript features — which probably is OK for many cases (simply by using // @ts-ignore). Turning a project into 100% TypeScript isn't done in a heartbeat. :)

Remove file extensions on import statements

If you've referenced files with the full path including the extension, you may fail on running the code, but not on transpiling with tsc.

# Bad
import MeasureTime from "./include/measuretime.js";

# Good
import MeasureTime from "./include/measuretime";

Do a search/replace as:

Search:   (import.*)['|"](.*).js['|"]
Replace:     $1 '$2'

Excluding folders from transpile

If you have folders at the same level as src you may get this error:

TS6059: File 'abc' is not under 'rootDir'

Exclude the folders by excluding folders in tsconfig.json, but make sure the TS_NODE_FILES is set (if its true, include/exclude will be used)

"include": [
	"src/**/*.js",
	"src/**/*.ts"
],
"exclude": [
	"test",
	"bin",
	"out",
	"additionalFolderToSkip"
]

Inferring types (aka automatic documentation)

This part of the document contains the best tip :) Consider the following code. We can easy understand that the FindUser takes a parameter userID, and by looking at the code it's both you and TypeScript understands its a string.

const user = FindUser("John");
if (user)
    console.log(`User found. Name=${user.Name}`);

function FindUser(userID) {     // No type on param or return

    if (userID === "John")
       return { Name: "John", Age: 99 };
    else
        return undefined;
}

.. but it would be better if it looked like this:

function FindUser(userID: string) : { Name: string, Age: number } | undefined {     // We're clear on expectations!

    ... code follows

}

Either write this by hand, or use the following tricks:

To infer the parameters, click a parameter and allow the yellow suggestion appear “Infer parameter from usage”. This will examine the code determinate what the parameter's type is. Sometimes the result isn't right, so check that it makes sense.

Now return parameters can also be inferred. Set the cursor the cursor needs to be at the name of the function and press ctrl + shift + r. Optionally use the command palette and search for “Refactor…”.

One final tip is to use this style for return parameters. This way of structuring the code ensures the type is only written once.

function FindUser(userID: string): typeof result {

  let result: { Name: string, Age: number } | undefined = undefined;

  if (userID === "John")
    result = { Name: "John", Age: 99 };

  return result;

}

Alternatively use this style, but methods returning a Promise will be challenging:

function FindUser(userID: string): { Name: string, Age: number } | undefined {

    let result: ReturnType<typeof FindUser> = undefined;

    if (userID === "John")
        result = { Name: "John", Age: 99 };

    return result;

}

Resolving errors in .ts-files

Object is possibly 'undefined'

Since the tsconfig.json specifies strickNullChecks: true , then TypeScript will give errors for all code that hasn't specifically checked for undefined. This results in the following error:

TS2532: Object is possibly 'undefined'.

As an example: The sought user may not be found and user.SendResetPasswordLink() will therefore fail.

# Bad code
let user = userlist.find("admin");
user.SendResetPasswordLink();  // No check if user is undefined

# Correct code:
let user = userlist.find("admin");
if (user)
   user.SendResetPasswordLink();

Function arguments

All arguments must be used or specified as optional.

TS2554: Expected 2 arguments, but got 1.

Example:

let user = FindUser("admin");
function FindUser(userName: string, region: string) {
    // ... implementation here
}

Solution is either to call with both parameters, make region optional or have a default:

function FindUser(userName: string, region?: string) {
    // ... region can now be a string | undefined.
}

function FindUser(userName: string, region= "Europe") {
    // ... region can now be a string
}

async function and the keyword Promise

If you write an async function, the return value of the function must be a Promise. Consider this function:

function UserExists(userID: string): boolean {
    return (userID === "John");
}

If it needs to be async, then the return parameter will change from boolean to Promise .

async function UserExists(userID: string): Promise<boolean> {
    // Additional code here requiring it to be async.. :)
    return (userID === "John");
}

Aren't many objects “any” now?

Well, yes. let a; is basically equal to let a : any;

This means you can do whatever you like with the object (as in JavaScript), but that's not what we really want with going TypeScript. We want control and knowing what we're working with.

If you want to disable the use of any, then open tsconfig.json and change noImplicitAny from false to true and enjoy the errors :)

Using {} in TypeScript.

This may be working in JavaScript, but is annoying in TypeScript. A variable will in be declared as just {} , and nothing more. Example:

let myBox = {};
myBox.contents = "fruit";
# ^^^ Will render "TS2339: Property 'contents' does not exist on type '{}'."

If you absolutely want to use {}, then you can resolve this issue atleast three different ways:

# Set the type (Works since contents is optional)
let myBox1: { contents?: string } = {};
myBox1.contents = "fruit";

# Better
let myBox2: { contents: string };   // Variable is unused
myBox2 = { contents: "fruit" };

# Or in this case, best (the type for myBox3 is inferred automatically)
let myBox3 = { contents: "fruit" };

# Basically turn of type checking (not recommended)
let myBox4 : any = {};
myBox4.contents = "fruit";

Type 'xyz' is not assignable to type 'never' (part 1)

Doing for example .push on arrays that has been defined within an object will give this error.

Another simpler example:

let users = [];
users.push("John");

will result in:

TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.

The solution is to specify the type:

let users: string[] = [];
users.push("John");

If the type is unknown at the time of doing the fix, do:

let users: any[] = [];

// or, if possible, use unknown. It's a stronger type

let result : unknown[] = [];

Type 'xyz' is not assignable to type 'never'.. (part 2)

For objects with arrays, there are atleast three different solutions to this problem.

const payload = {
    name: "Tomas",
    accessrights: []       // Will become "never[]"
};

payload.accessrights.push("Admin");

// Error: "Argument of type 'string' is not assignable to parameter of type 'never'."

Solution 1 — Inline types

const payload: {
    name: string,
    accessrights: string[]
} = {
    name: "Tomas",
    accessrights: []
};

Solution 2— Define a type (or interface)

type ThePayload = { name: string, accessrights: string[] }
// interface IThePayload { name: string, accessrights: string[] }

const payload: ThePayload = {
    name: "Tomas",
    accessrights: []
};

Solution 3— type inference

This one is the fastest implementation, but it gives less readable code:

const payload: ThePayload = {
    name: "Tomas",
    accessrights: <string[]>[]
};

Dates and milliseconds

You cannot do operations like this:

let t1 = new Date();
let t2 = new Date();
console.log(t2 - t1);

With TypeScript you need to be precise and specific:

let t1 = new Date().getTime();
let t2 = new Date().getTime();
console.log(t2 - t1);

Types differ / TypeScript is smart

Consider the following code. a is a string, and b can be a string or undefined. How can the assignment on line 3 occour?

let a: string;
const b: string | undefined = "Banana";
a = b;

Well, TypeScript is smart. It undestands that b is a string. Hovering over b gives:

If statements is the best way to visualize this. Foo can clearly be a string or undefined, but after the if-statement it can only be a string:

How strict should the destination code be?

Well, that's a matter of opinion. The following settings controll most of the strictness:

tsconfig.json — strictNullChecks

With this setting on all code needs to handle undefined properly.

function GetTemparature(): number | undefined {
    // A piece of code should return the temparature,
    // but that occasionally returns undefined
}

const temp = GetTemparature();
if (temp > 50)  // Error, temp may be undefined
    console.log("It's hot.");

tsconfig.json — useUnknownInCatchVariables

If this setting is on, the variable used in catch must be handled properly:

Bad code:

try {
   // Some bad code that generates an exception
} catch (error) {
   console.log(error.message);  // Fail!

Good code:

try {
   // Some bad code that generates an exception
} catch (error) {
   if (error instanceof Error)
       console.log(error.message);  // OK!
}

tsconfig.json — noImplicitAny

When set to true, it's not allowed to use any :

const user: any = { name: "Tomas", Foo: "Bar" };

// @ts-ignore

Even if all the above settings are turned on, it's still possible to just ignore the errors. A completely converted code base should strive for having none to very few // @ts-ignore .

Of course, there are more tricks to converting, but I hope this guide will give you some hints.

Enjoy




Continue Learning