The Transpilation of TypeScript import statements

June 24, 2024

Kazi Ehsan Aziz

You may have seen TypeScript import statements like import { valA } from './hello2'. You may also have seen imports with file extensions like: import { valA } from './hello2.js'.


You may have run into the ERR_MODULE_NOT_FOUND error for some reason. Or maybe you are planning on upgrading your Node.js from an older runtime (< v19).


In any case, if you have some time, it might be helpful to understand how TypeScript import statements are really transpiled into JavaScript, how modules are loaded, and what modules even are!


Instead of addressing a particular problem, this article broadly aims to shed some light on JavaScript modules, a few tsconfig.compilerOptions fields (module, target), when to use type: module in your package.json and what conventions are popular when writing TypeScript import statements. The article also assumes the reader is familiar with common TypeScript development tools and JavaScript terminology.


JS Modules

Modules help to organise JavaScript codebases into separate files and scopes. In the early days of JavaScript, when it was used only in browser environments, there was no way to localise the scope of JavaScript code, all scripts running on a single web page would always run in the same global scope. There were some tricks to localise scope, such as the IIFE (Immediately Invoked Function Expression). First-forward to today (JavaScript is now used both as server-side code and in browsers), depending on how you reference an existing module in your code, there are (mostly) two types of modules in use for JavaScript:



You will also come across ES5, ES6 (ES2015), ES2020 etc. terms. These are JavaScript language specification versions. Not to be confused with modules particularly, but it is true that ES5 is an older specification that did not support ES modules. For ES5 and older code, CommonJS was used. ES modules or ESM support first arrived in ES6 (ES2015).


Now, let's take a simple TypeScript project and see some cases of compilations into JavaScript. We are using plain tsc to compile here.


case#1, hello.ts and hello2.ts are TypeScript ES modules. tsconfig "module": "CommonJS" and "target": "ES5".

  • hello.ts
  • hello2.ts
  • tsconfig.json
  • hello.js
  • hello2.js
  • Output
import { valA } from "./hello2";

const returnValA = (valA: string) => {
  return `valA from hello2 is ${valA}`;
};
console.log(returnValA(valA));
export const valA = "A";
{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES5",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true,
    "noEmitOnError": true
  },
  "files": ["hello.ts"]
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var hello2_1 = require("./hello2");
var returnValA = function (valA) {
  return "valA from hello2 is ".concat(valA);
};
console.log(returnValA(hello2_1.valA));
//# sourceMappingURL=hello.js.map
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.valA = void 0;
exports.valA = "A";
//# sourceMappingURL=hello2.js.map
$ node hello.js
valA from hello2 is A

case#2, hello.ts and hello2.ts are TypeScript ES modules. tsconfig "module": "CommonJS" and "target": "ES6".

  • hello.ts
  • hello2.ts
  • tsconfig.json
  • hello.js
  • hello2.js
  • Output
import { valA } from "./hello2";

const returnValA = (valA: string) => {
  return `valA from hello2 is ${valA}`;
};
console.log(returnValA(valA));
export const valA = "A";
{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES6",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true,
    "noEmitOnError": true
  },
  "files": ["hello.ts"]
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const hello2_1 = require("./hello2");
const returnValA = (valA) => {
  return `valA from hello2 is ${valA}`;
};
console.log(returnValA(hello2_1.valA));
//# sourceMappingURL=hello.js.map
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.valA = void 0;
exports.valA = "A";
//# sourceMappingURL=hello2.js.map
$ node hello.js
valA from hello2 is A

case#3, hello.ts and hello2.ts are TypeScript ES modules. tsconfig "module": "ES6" and "target": "ES6". Since we are compiling to "module": "ES6", we need to add "type": "module" to our package.json. Also, notice the change in TypeScript import syntax.

  • hello.ts
  • hello2.ts
  • tsconfig.json
  • hello.js
  • hello2.js
  • package.json
  • Output
import { valA } from "./hello2.js"; // notice the extension

const returnValA = (valA: string) => {
  return `valA from hello2 is ${valA}`;
};
console.log(returnValA(valA));
export const valA = "A";
{
  "compilerOptions": {
    "module": "ES6",
    "target": "ES6",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true,
    "noEmitOnError": true
  },
  "files": ["hello.ts"]
}
import { valA } from "./hello2.js";
const returnValA = (valA) => {
  return `valA from hello2 is ${valA}`;
};
console.log(returnValA(valA));
//# sourceMappingURL=hello.js.map
export const valA = "A";
//# sourceMappingURL=hello2.js.map
{
  // ...
  "type": "module"
  // ...
}
$ node hello.js
valA from hello2 is A

case#4, hello.ts and hello2.ts are TypeScript ES modules. tsconfig "module": "ES6" and "target": "ES5". This case is just for curiosity, there is no point in using old ES5 code features in a modern ES modules (ES6) system.

  • hello.ts
  • hello2.ts
  • tsconfig.json
  • hello.js
  • hello2.js
  • package.json
  • Output
import { valA } from "./hello2.js"; // notice the extension

const returnValA = (valA: string) => {
  return `valA from hello2 is ${valA}`;
};
console.log(returnValA(valA));
export const valA = "A";
{
  "compilerOptions": {
    "module": "ES6",
    "target": "ES5",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true,
    "noEmitOnError": true
  },
  "files": ["hello.ts"]
}
import { valA } from "./hello2.js";
var returnValA = function (valA) {
  return "valA from hello2 is ".concat(valA);
};
console.log(returnValA(valA));
//# sourceMappingURL=hello.js.map
export var valA = "A";
//# sourceMappingURL=hello2.js.map
{
  // ...
  "type": "module"
  // ...
}
$ node hello.js
valA from hello2 is A

tsconfig options

Let's discuss some of the tsconfig options used above.


module

How import statements are transpiled. Remember, there are 2 types of modules in JS: CommonJS (require) & ESM (import).

Default value: CommonJS if target is ES5; ES6/ES2015 otherwise.

Other values: es2020, es2022, esnext, preserve, node16, node18, nodenext.

In addition to the base functionality of ES2015/ES6, ES2020 adds support for dynamic imports, ES2022 further adds support for top level awaits. esnext & preserve are good for bundled projects. nodenext: its behavior changes with the latest stable versions of Node.js.

Further reading: TypeScript Compiler Options - module


target

The target setting changes which JS features are downleveled and which are left intact. For example, an arrow function () => this will be turned into an equivalent function expression if target is ES5 or lower.

Default: ES5.

Other vals: es6/es2015, ... es2022

Modern browsers support all ES6 features, so ES6 is a good choice.

Further reading: TypeScript Compiler Options - target


Scenarios and solutions

(Experimented on Node 20)

Now lets take a look at some real-life scenarios and their solutions. But first, a note on case#3 from the previous section. If type is not set in package.json, then transpilation from TS to JS will still run. import { valA } from "./hello2.js" will transpile to import { valA } from "./hello2.js".

Upon running the transpiled JS, it will work with a warning:

[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file://{your-project-path}/dist/hello.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to {your-project-path}/package.json.

So, it was being converted to "type": "module" after detecting syntax. You should set type in this case to avoid that check and conversion overhead.

If you had explicitly set "type": "commonjs", the imports would not even be recognised.


Scenario#1

You are using something other than node16, node18, node20 or nodenext in your tsconfig.compilerOptions.module. Let's say you have the following options set in tsconfig.compilerOptions:

{
  // ...
  "module": "ES6",
  "target": "ES6"
  // ...
}

type is not set in package.json and you are using extension-less import statements. import { valA } from "./hello2" will transpile successfully to import { valA } from "./hello2".

Running this will throw ERR_MODULE_NOT_FOUND and exit. They can't find any module called "hello2".

Just adding "type": "module" in package.json in this case won't change anything, because when tsconfig.compilerOptions.module is ES6, "type": "module" is assumed even if not set, as showcased earlier. Error happens for Node.js runtime because Javascript ES Modules need file extensions in import statements (mandatory-file-extensions).

To pass set "type": "module", and do one of:


Scenario#2

You are using default config packages in tsconfig like @tsconfig/node20 that have compilerOptions.module set to nodenext. You have not set type in package.json and you are using extension-less import statements like import { valA } from "./hello2".

Building the project results in CommonJS modules, that run without any problem. From docs, node16, node18, node20 and nodenext can emit in either CommonJS or ESM format. Actually, "type": "commonjs" is being assumed here during transpilation. If "type": "module" is set, ESM format is emitted but the build (transpilation process) does not complete. It ends in a build error:

TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './hello2.js'?

Error mentions moduleResolution (another tsconfig.compilerOptions field), cause when module is nodenext, moduleResolution is also nodenext if not set explicitly. Trying to run this will cause the ERR_MODULE_NOT_FOUND runtime error. So, "module": "nodenext" does emit both CommonJS or ESM depending upon the type value in package.json.

So, here, the way to pass is to just remove type from package.json and let CommonJS be assumed.

Or, if you want to set "type": "module", then use .js extension manually in your imports. External packages (resolve-tspaths or tsc-alias) wont help because they come in after the transpilation step and transpilation is failing in this case.

If you can't manually set .js extension, then you should look at bundler setups.