Appending .js extension on relative import statements during Typescript compilation (ES6 modules)

This seems to be a trivial problem, but it is not very obvious what settings/configurations need to be used to solve this issue.

Here are the Hello World program directory structure and the source code:

Directory Structure:

| -- HelloWorldProgram
     | -- HelloWorld.ts
     | -- index.ts
     | -- package.json
     | -- tsconfig.json

index.ts:

import {HelloWorld} from "./HelloWorld";

let world = new HelloWorld();

HelloWorld.ts:

export class HelloWorld {
    constructor(){
        console.log("Hello World!");
    }
}

package.json:

{
  "type": "module",
  "scripts": {
    "start": "tsc && node index.js"
  }
}

Now, execution of the command tsc && node index.js results in the following error:

internal/modules/run_main.js:54
    internalBinding('errors').triggerUncaughtException(
                              ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'HelloWorld' imported from HelloWorld\index.js
Did you mean to import ../HelloWorld.js?
    at finalizeResolution (internal/modules/esm/resolve.js:284:11)
    at moduleResolve (internal/modules/esm/resolve.js:662:10)
    at Loader.defaultResolve [as _resolve] (internal/modules/esm/resolve.js:752:11)
    at Loader.resolve (internal/modules/esm/loader.js:97:40)
    at Loader.getModuleJob (internal/modules/esm/loader.js:242:28)
    at ModuleWrap.<anonymous> (internal/modules/esm/module_job.js:50:40)
    at link (internal/modules/esm/module_job.js:49:36) {
  code: 'ERR_MODULE_NOT_FOUND'
}

It is obvious that the problem seems to have been originated from the fact that in index.ts Typescript file there is no .js extension in the import statement (import {HelloWorld} from "./HelloWorld";). Typescript didn't throw any error during compilation. However, during runtime Node (v14.4.0) wants the .js extension.

Hope the context is clear.

Now, how to change the compiler output setting (tsconfig.json or any flags) so that local relative path imports such as import {HelloWorld} from ./Helloworld; will get replaced by import {HelloWorld} from ./Helloworld.js; during Typescript to Javascript compilation in the index.js file?

Note: It is possible to directly use the .js extension while importing inside typescript file. However, it doesn't help much while working with hundreds of old typescript modules, because then we have to go back and manually add .js extension. Rather than that for us better solution is to batch rename and remove all the .js extension from all the generated .js filenames at last.


To fellow developers who are looking for a solution to this issue, the possible work-arounds we have come across are as follows:

  1. For new files, it is possible to simply add ".js" extension in the import statement in Typescript file while editing. Example: import {HelloWorld} from "./HelloWorld.js";

  2. If working with old projects, rather than going through each and every file and updating the import statements, we found it easier to simply batch rename and remove the ".js" extension from the generated Javascript via a simple automated script. Please note however that this might require a minor change in the server side code to serve these extension-less ".js" files with the proper MIME type to the clients. If you want to avoid this, you may instead want to use regular expression to batch find and replace the import statements recursively to add the .js extension.

Side note:

Regarding the TS team's failure on resolving this issue, it appears that there is a tendency to try to blow up this issue out of context than what it really is and attach that to some design principles to defend.

However, factually this is nothing more than an issue with how the compiler deals asymmetrically with the extension. The Typescript compiler allows import statement without an extension. It then goes on to add ".js" extension to the corresponding output filename while the file is being translated, but for the corresponding import statements where this file is referenced it ignores the fact that it has added ".js" extension during translation. How can this asymmetricity be defended by the out of context URI rewriting principles?

There is a fixed one to one correspondence between the Typescript file and the generated Javascript output file during compilation. If the referenced import does not exists, the compiler would throw an error. The files wouldn't even compile! So, out of context or non-compilable examples mentioning the possibility of other conflicting URIs invalidate such claims.

If the compiler simply generated extension-less output files it would also solve the issue. But, would that also somehow violate the design principle regarding URI rewrites? Certainly, in that case there could exist other design principles to defend the position! But wouldn't such stubbornness only help to further validate the adamancy or ignorance of the TS team on this issue?


TypeScript cannot possibly know what URI you are going to use to serve your files, therefore it simply must trust that the module path you gave it is correct. In this case, you gave it a path to a URI that doesn't exist, but TypeScript cannot know that, so there is nothing it can do about it.

If you are serving the module with a URI that ends in .js, then your module path needs to end in .js. If your module path doesn't end in .js, then you need to serve it up at a URI that does not end in .js.

Note that the W3C strongly advises against using file extensions in URIs, because it makes it harder to evolve your system, and advocates to instead rely on Content Negotiation.

Rewriting paths would break a couple of fundamental design principles of TypeScript. One design principle is that TypeScript is a proper superset of ECMAScript and every valid ECMAScript program and module is a semantically equivalent TypeScript program and module. Rewriting paths would break that principle, because a piece of ECMAScript would behave differently depending on whether it is executed as ECMAScript or TypeScript. Imagine, you have the following code:

./hello

export default "ECMAScript";

./hello.js

export default "TypeScript";

./main

import Hello from "./hello";

console.log(Hello);

If TypeScript did what you suggest, this would print two different things depending on whether you execute it as ECMAScript or as TypeScript, but the TypeScript design principles say that TypeScript does never change the meaning of ECMAScript. When I execute a piece of ECMAScript as TypeScript, it should behave exactly as it does when I execute it as ECMAScript.


As many have pointed out. The reason why Typescript doesn't and will never add file extension to import statements is their premise that transpiling pure javascript code should output the same javascript code.

I think having a flag to make typescript enforce file extensions in import statements would be the best they could do. Then linters like eslint could maybe offer an auto fixer based on that rule


you also can add nodejs CLI flags for enable node module resolution:

  • for importing json --experimental-json-modules
  • for importing without extensions --experimental-specifier-resolution=node

I know --experimental-specifier-resolution=node has a bug (or not) then you cannot run bin scripts without extensions (for example in package.json bin "tsc" wan't work, but "tsc":"tsc.js" will work). To many packages has bin scripts without any extensions so there is some trouble with adding NODE_OPTIONS="--experimental-specifier-resolution=node" env variable