TypeScript: Compiled JavaScript import is missing file extension

TypeScript Version: 4.0.3 Search Terms: module es6 es2015 import file extension missing js ts bug 404

Steps to reproduce:

Create a main.ts:

import {foo} from './dep';
console.log(s, foo);

Create a dep.ts:

export const foo = 42;

Create a tsconfig.json:

{
  "compilerOptions": {
    "module": "ES2015"
  }
}

Then run:

npx tsc

Expected behavior:

The compiler generates JavaScript “which runs anywhere JavaScript runs: In a browser, on Node.JS or in your apps” (according to the TypeScript homepage). For example, it would be valid to create two files as follows; main.js:

import { foo } from './dep.js';
var s = "hello world!";
console.log(s, foo);

And dep.js:

export var foo = 42;

(More generally, the expected behavior is that the module specifier in the generated import must match the filename chosen for the generated dependency. For example, it would also be valid for the compiler to generate a file dep.xyz, if it also generated import ( foo } from './dep.xyz'.)

Actual behavior:

As above, except that in main.js, the import URL does not match the filename chosen by the compiler for the generated dependency; it is missing the file extension:

import { foo } from './dep';

When executing main.js in the browser, it requests the URL ./dep, which is a 404. This is expected, as the correct relative URL would be ./dep.js.

Related Issues: https://github.com/microsoft/TypeScript/issues/13422, voluntarily closed by the reporter for unknown reasons

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 58
  • Comments: 65 (4 by maintainers)

Commits related to this issue

Most upvoted comments

@RyanCavanaugh Writing “./dep.js” doesn’t sound logical. The file dep.js does not exist in the Typescript universe. This approach requires the coder to know the exact complied output and be fully aware of the compiled environment. It’s like having to know the CIL outputted and modify it here and there in order to code in C# successfully. Isn’t the whole idea of Typescript to abstract away Javascript?

import { foo } from "./dep" is legitimate Typescript, and it provides the information for Typescript to resolve all that is needed to type check and make the code compile successfully. So, the compiled output should work. Typescript should not be generating syntactically incorrect Javascript.

IMHO, this issue should be a bug.

TypeScript developers, please, do whatever the hell you want with file extensions, this debate has been going on for years, I don’t care, your mind is set, fine. But just document this very basic thing in a place that’s easy to find: how on earth do I run the JavaScript emitted by tsc when “module” is set to “ESNext”? That will save a lot of people a lot of time. Most people understand “transpiles to JavaScript” as “transpiles to working JavaScript”. But this is not the case.

This issue has been marked ‘Working as Intended’ and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Thanks @RyanCavanaugh! Do you mean I should write import { foo } from './dep.js' in my source main.ts? That does actually seem to work! In that the compiler uses './dep.ts' to find the types of variables exported.

However, I find this surprising, because

  • The recommendation everywhere else in TypeScript docs is to omit the file extension.
  • If I try to import from './dep.ts', the compiler gives me error TS2691: An import path cannot end with a '.ts' extension. Consider importing './dep' instead. So apparently it’s not possible to generate JavaScript imports that end in .ts?
  • The module resolution docs don’t say what’s going on here. What logic makes this work? Is there a special case for .js, which is mapped to .ts?
  • I can’t find a rationale for this design anywhere.

TypeScript doesn’t modify import paths as part of compilation - you should always write the path you want to appear in the emitted JS, and configure the project as necessary to make those paths resolve the way you want during compilation

The whole idea of TypeScript is to add static types on top of JavaScript, not to be a higher-level language that builds to JS.

Indeed, Typescript is a “superset of Javascript”. But what means to be a superset? It means:

  1. you add more features to Javascript,
  2. you compile those added features to be valid Javascript.

The most basic Typescript additional rule is: “you can type a variable”. Like this:

const value: string = "foo"

which will be transformed into const value = "foo".

Okay. You add a feature. You compile your code written in your superset language into a valid version of the target language and everything works fine.

Now there is another feature that is added by Typescript: “the file extension is optional when importing ts/js files”.

You can write:

import { imported } from "./my-module.js"

which is valid Javascript and valid Typescript. That’s normal: Typescript is a superset.

But because of this Typescript-specific rule (“the file extension is optional when importing ts/js files”) (not a Javascript rule), you can also write (in Typescript only):

import { imported } from "./my-module"

which is valid Typescript but not valid Javascript. The proof: it won’t execute. Neither by a browser, Node or Deno.

If you add a feature as a superset of a target language, then you have to compile your feature into a valid output of your target language.

And yes, “the file extension is optional when importing ts/js files” is definitely a Typescript-specific feature 😉

The whole idea of TypeScript is to add static types on top of JavaScript, not to be a higher-level language that builds to JS. The C# / IL comparison is not apt at all.

There’s literally no line of undownleveled JavaScript code you can write where TS intercepts some string in it and changes it to be something else. This is 100% consistent with TS behavior in every other kind of JS construct; it would be frankly bizarre to have import paths be the one thing that we decide to go mess with.

This whole discussion is absurd. Valid typescript obviously transpiles to invalid ES6 code. I do not understand in which world this can be classified as “works as intended” behavior. Adding “.js” to import statements which actually refer to “.ts” files as workaround is too wild for me. For my current project I have free choice, therefore I’m gonna switch over to CommonJS target module code. CommonJS works without file extensions. In case one can live with CommonJS target module, this is an option to get around this problem.

Same issue here. Adding a .js in the import inside a TypeScript file does allow to compile it with the TypeScript compiler and will output files with working ESM imports.

However, when I add .js extensions on the imports, I can’t get testing working. Tried with Mocha and Jest, but no luck so far: they complain the files with .js extension don’t exist, which is correct, those files don’t exist since they actually have a .ts extension.

I would love to see the TypeScript compiler add .js extenstions on imports when the output is esm/es2015.

Thanks for sharing your workaround @bobbyg603 . I’m not sure how that would work for the imports inside src/my-class.ts, (nested). You can’t change that dynamically depending on your context (testing or actually using).

It feels to me like there is a serious mismatch between plain ES modules (requiring actual existing paths with proper file extensions) and TypeScript code (relying on “smart” nodejs module resolution, not requiring file extensions, and (implicitly) changing the (implicit) file extensions from .ts to .js when compiling). I can’t understand this issue is marked “Working as intended” in https://github.com/microsoft/TypeScript/issues/40878#issuecomment-702353715, this is a problem that needs to be addressed.

Most logical to me would be to write imports with *.ts extension in your code (matching the actual file extension). And when compiling, have TypeScript replace *.ts extensions with *.js, matching the actual file extension of the compiled file (which is changed to *.js by TypeScript). TypeScript changes the file extensions from *.ts to *.js, so logically it should also fix corresponding imports.

TS never takes an existing valid JS construct and emits something different, so this is just the default behavior for all JS you could write. Module resolution tries to make all of this work “like you would expect”

See also https://github.com/microsoft/TypeScript/issues/15479#issuecomment-300240856

Expectedly I stumbled upon this when setting a simple Node.js module written in TypeScript and compiled to CommonJS and ES to cater for both cases. I agree with @ericmorand and other similar opinions that, given a valid TypeScript code and valid TS configuration to target ES, the compiler SHOULD generate valid ES code. Otherwise, it should be considered a BUG. If it’s a design goal to not generate valid code from TS code, then the design is flawed and needs to be fixed too.

I also just stumbled over this issue and as usual I find the justifications of “working as intended” as really questionable. There are various settings in the compiler which steer the module system. If I set "module": "commonjs" my typescript import statements are rewritten to require. There is also esModuleInterop which adds some extensions around the module imports.

While I agree that maybe the default behavior should be in the way that TypeScript leaves everything untouched, I also would say that it should/must be a TypeScript compiler setting to handle the mentioned use cases. I wanted to use TypeScript to create a small command line tool, which I can use in my GitHub Actions Workflow (without really making a reusable action) and it just does not work because the TypeScript output is not compatible with Node.js. Node.js seems to be usually a first class citizen in TS and here some very high level rules on the design goals (which IMO only partially apply here) prevent many people to use TS in such an obvious chain as running the compiled output in Node.js.

Maybe we have better chances with the Node.js guys to extend the module resolution algorithm.

@RyanCavanaugh that principle makes sense. My remaining confusion then is why the docs and compiler encourage omitting the file extension, if “you should write the import path that works at runtime”. Maybe this encouragement is designed for a JS runtime that demands that you omit the file extension. But the runtimes I’m aware of are browser, ES modules, and Node.js, none of which make this demand.

good to see this crazy conversation still going after 2 years

its amazing to me that something so basic as module resolution continues to be one of the most frustrating things about the nodejs ecosystem

I cannot wait for deno to gain more traction and put this issue to bed by enforcing esm

to the TS team, listen to your users and add a compiler flag

@RyanCavanaugh How can emitting Javascript without a compilation error but that doesn’t run be anything other than a bug?

If we have to have the .js extension then there should at least be a compilation error if it is missing.

The whole idea of TypeScript is to add static types on top of JavaScript, not to be a higher-level language that builds to JS.

The whole idea of Typescript it to output valid Javascript code or give me an error if it can’t. Currently with this issue the compiler happily generates the code which then fails at runtime. This means that you can’t use ESM modules in production. You write some code, it compiles and it then fails at runtime. If I wanted to write unit tests to cover stuff the compiler could be doing I might as well stick to Javascript.

There’s literally no line of undownleveled JavaScript code you can write where TS intercepts some string in it and changes it to be something else

I’m not exactly sure what this means but if I set the module compiler option to commonjs the compiler happily translates my imports to require statements. So why it is unacceptable to also translate my import statements for ESM modules?

add .js extenstions on imports when the output is esm/es2015

I think add .js extenstions on imports when the output is esm/es2015 would be the only correct behaviour, because The file extension is always necessary for relative specifier in esm.

I think this issue should be reopened. The purpose of the TypeScript compiler is to build into a valid JavaScript code.

When the compiler is configured to target ES5, it creates valid ES5 code. When the compiler is configured to target ES6, it creates non-valid ES6 code.

This is a bug, plain and simple: from on a perfectly valid TypeScript source, the compiler build a non-valid ES6 code:

lib.ts Perfectly valid TypeScript code

export const start = () => {

}

index.ts Perfectly valid TypeScript code

import {start} from "./lib";

start();

node dist/index.js

Error [ERR_MODULE_NOT_FOUND]: Cannot find module ‘[…]/dist/lib’ imported from […]/dist/index.js

The problem with this solution is that if you have an “outDir” different than the default, i.e. a setup where transpiled files don’t live beside the .ts files, most tools will freak up and that’s not a nice development experience.

I was so pissed-up buy this that I built this monstrosity: https://www.npmjs.com/package/add-js-extension

You can install it and have it watch your “outDir” and it will quickly and transparently add the .js extension where appropriate to the transpiled files as they appear / are updated by tsc.

It seems to be working well. Only missing major feature is that I currently don’t rewrite the source-maps but it doesn’t seem to matter much since the changes I make in the js files are only adding 3 chars to some import statements at the end of some lines. No line offets, no changes to any place where you would be likely nor even able to set a breakpoint anyways.

Give it a go and let me know of any issues you might find!

There are webpack modules that do the same thing, but my solution is much less invasive: keeps file and directory structure, no bundling, no config.

I spent a considerable amount of time reading the obscure ESM spec and I’m pretty confident my tool doesn’t do anything unwise.

I actually think TS has it wrong, as the ESM spec clearly states that there is no such thing as a default file extension. So you should “import foo from ‘. /foo.ts’” and that should transpile to “import foo from ‘./foo.js’”. I don’t see any deviation from JS here. “import foo from ‘./foo’” seems just wrong.

On Thu, Jul 29, 2021, 22:43 RA80533 @.***> wrote:

@TheBoneJarmer https://github.com/TheBoneJarmer If you’re just doing plain TS and JS, use the would-be filename as the import specifier like so:

import { version } from “./version.js”;

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/microsoft/TypeScript/issues/40878#issuecomment-889442439, or unsubscribe https://github.com/notifications/unsubscribe-auth/AALESE6S37QUCIGMC3IKOTLT2G4NRANCNFSM4SA23LJQ .

@RA80533, no, it creates non-valid ES6 code when given valid TypeScript code.

I’ve created a pull request wich wich solve this for raltive imports, see:

https://github.com/microsoft/TypeScript/pull/47436

I’ve added a compiler switch:

   appendModuleExtension

An alternative way to make this work without adding dependencies is to use Node’s --es-module-specifier-resolution=node flag. For example, node --es-module-specifier-resolution=node dist/index.js, where index.js would itself have ES-style imports without the .js file extension.

When the compiler is configured to target ES6, it creates non-valid ES6 code.

It creates non-valid ES6 code when given non-valid ES6 code. The first line in index.ts should be rewritten like so:

import {start} from "./lib.js";

@silassare I think probably a thousand person, me included, have written similar scripts. This is just absurd.

YES. Configuring TS projects is such a huge pain now. There’s always some bit of your toolchain that’s gonna be broken.

If TS wanna do away with JS entirely then fine by me, I love TS.

Just officially create TS as a new independent language that way you will have a valid reason for not being capable to transpile to JS. More valid than inability to parse module paths.

I’m sorry for the ranting, but that issue hass been really pissing me off for a long time.

As previously mentioned in this thread this led me to write an abomination software that corrects the extensions by watching outDir but I’m sad to have to resort to stupid hacks when we have an otherwise seemingly bright team that has developed incredible language features.

On Mon, Oct 11, 2021, 04:58 Alex (Huy Tran) @.***> wrote:

Expectedly I stumbled upon this when setting a simple Node.js module written in TypeScript and compiled to CommonJS and ES to cater for both cases. I agree with @ericmorand https://github.com/ericmorand and other similar opinions that, given a valid TypeScript code and valid TS configuration to target ES, the compiler SHOULD generate valid ES code. Otherwise, it should be considered a BUG. If it’s a design goal to not generate valid code from TS code, then the design is flawed and needs to be fixed too.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/microsoft/TypeScript/issues/40878#issuecomment-939636422, or unsubscribe https://github.com/notifications/unsubscribe-auth/AALESEYFDGKURLLLU7GWGZLUGJHDVANCNFSM4SA23LJQ .

@TheBoneJarmer If you’re just doing plain TS and JS, use the would-be filename as the import specifier like so:

import { version } from "./version.js";

So far, I have had success with @RyanCavanaugh’s suggestion to “always write the path you want to appear in the emitted JS, and configure the project as necessary to make those paths resolve the way you want during compilation”.

But I am still confused because this advice contradicts the compiler and docs and ecosystem, which all encourage omitting file extensions.

I’m hitting this exact same issue. I’m not using webpack, just plain typescript and my javascript output is broken because the .js extension is missing. How on earth can this not be a bug? What am I supposed to do instead?

Facing the same problem.

In most scenarios, we use webpack. But for simple scenarios and samples, e.g. for our TS-training for developers, we want to use plain TS files and JS modules without the complexity of another transpiler like Babel/webpack. It looks odd in training classes when you try to explain the beauty of TS and then have to stutter something like “TS never changes outputted strings”. Or you have to tell them that it will get better with webpack. => “The simple case is ugly but it looks good in the more complicated case when we use more tools.”

This means:

  • On the client/browser, TS needs a transpiler to transpile to some non-ECMASCRIPT-modules.
  • If you go go for “import ./myModule.js”, IDEs might mark it as error.
  • Other ALM-scripts which parse source-code (testscripts, mock-scripts, build-scripts, deploy-scripts, …) might get confused.

Why not add an option to tsconfig and leave it to the developer to use it or not? We know that is is not “pure doctrine” but for many cases it will work. One could also emit a warning “will only work with static files” or something like that.

I’m aware that “import ./myModule” could resolve to something which is not a static file on the web-server. E.g. a web-service dynamically resolving and returning js-code.

In any case, please such an option would leave it to the developer’s resonsibility how to transpile it.

at least the imports are also translated to require calls, there is no argument to not translate to correct imports

I will add that I indeed created a library called tsc-esm as a workaround for this bug, and even though it works quite fine every time I use it (ie 100% of the times I need to create a library written in Typescript) I feel I should not have to patch the output of the Typescript compiler like I do.

I look forward to the day when I can tag my library as “deprecated” 😃

This conversation is really frustrating. While I can understand not adding the .js suffix under the rationale of not modifying Javascript (though I disagree with it), what I cannot understand is the compiler happily producing code that will fail to run. There should at least be an option to fail compilation if I don’t use a .js suffix in the import. I don’t even care if it’s a default option, as long as it’s exposed.

In the meanwhile, what do people recommend doing? What’s the safest way to make sure tsc doesn’t produce Javascript that will fail in this instance? I’m using node 16 and would really like top level awaits.

Just leaving my +1 here… can’t believe this is still an issue.

Would be as simple as to allow using .ts extensions and then transform them into .js.

This would make everyone happy: hundreds of people asking for it and TS maintainers because TS principles will not be affected this way (read this comment).

Hope TS maintainers will reconsider this soon.

(Continues here)

We should fork TypeScript. Let’s call it FreeScript for instance.

There are way too many things in the hands of Microsoft right now and they go way too well together: they have GitHub, Copilot (I’m in the preview and it’s pretty freaky), TypeScript, VSCode…

It’s too good to be true. Our hands are tied. I have kind of a bad feeling about this. Could turn out the wrong way.

I’d love something like a mix between TypeScript and Webpack. Except that you wouldn’t have to bundle up everything using hard to understand and hard to configure plugins of plugins of plugins. But a lightweight, carefully designed, and extensively tested layer of plugins on top of tsc could be very nice.

Imagine how the language could grow if you could add rules to tsc via plugins, like they do in eslint. Without any changes to JavaScript eslint managed to get to a pretty good level of static analysis. Just imagine what the community could do using TypeScript instead of JS.

How many times have I encountered a situation in TS where it wouldn’t compile even-though there was no way in hell the code could fail.

Like:

if (someArray.length > 0) { const x = someArray.pop(); console.log(x.y); }

Doesn’t compile with strictNullChecks, x might be undefined. But i’m pretty sure it may not.

But I digress, sorry.

On Fri, Jul 30, 2021, 09:09 RA80533 @.***> wrote:

When the path ends with a / the server automatically converts that to /index.html.

For a bit of clarity, this behavior is due to the way modern web authors tend to serve content. Web servers as a whole are glorified file systems. A request to / is semantically equivalent to inspecting the root directory yet, because web servers have optimized the browsing experience in a manner such that they aren’t beholden to typical file system behaviors, that would-be directory request is often treated as a file request through some arcane redirection.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/microsoft/TypeScript/issues/40878#issuecomment-889681075, or unsubscribe https://github.com/notifications/unsubscribe-auth/AALESE2LHCFXB5YZSZEWIF3T2JFZFANCNFSM4SA23LJQ .

That said. I also took my time to learn a bit more about the whole es6 module system and I also think javascript has a flaw here. Imho they should introduce something similar to what happens in urls with index.html. In most if not all web servers you never have to manually insert ‘/index.html’ at the end of an url. When the path ends with a / the server automatically converts that to /index.html. When no file extension is added at the end of an import statement, the client should convert this first to .mjs. If no such file exists than to .js. And only then the client should throw an error if neither files were found.

Therefore I more or less can understand why typescript devs don’t consider this a bug from their side. Typescript merely interprets the import statement for typescript purposes but may not be able to convert it that easily to esm. But I do think this should be a config option.

I mean, several users already created their own npm package out of frustration which I think shows how big this problem is. And they are right to be frustrated because my basic typescript app produces broken javascript right now, which is a definitely a bug from typescript. And the fact that typescript devs do not want to admit that is just plain ignorance and stupid. This will only push people away from using TS. And on top of that, the fact that they say they won’t do a thing about it does pisses me off even more.

Imho the tsc should stop conversion when an import statement cannot be converted to esm. Or throw a warning or hint that narrows down to “Due to limitations in the javascript language you are supposed to add a .js in your import statement or else tsc will produce broken code”. And I’d be fine with that but not the way it is now.

So weird. In VSC if you Cmd+click on a import something from 'something.js'; you’ll get to the .ts file, like VSC knows that this issue exists and “fixes it”.

The recommendation to preemptively append .js extensions to TypeScript source is not only counterintuitive, it breaks ts-jest tests. It seems to me that TypeScript is in the wrong here. Why ban .ts extensions from import statements and encourage leaving them off altogether if the extension is resolved by the language server but not the compiler?

I just encountered this issue while attempting to port a library to TypeScript. I didn’t have to deal with it in prior projects because I was building apps with a bundler. Now that I’m emitting modules that can be tree-shaken downstream, this behavior, intended or not, leaves me in a bit of a pickle.

@djfm no, unbelievable

problem is, ts does not only need to add ‘.js’, there are complexer resolution strategies.

i would like it, if there would be a way of calling a plugin after transpilation had been done, so I could fix the import names with that. I know I could use a bundler / build system, but we ship directly the code typescript generates, without an additional build step. at the moment we fix the import path’s in our own webserver.

@adamshaylor for what it’s worth, vitest supports .js imports in test code and is largely API-compatible with Jest.

When the path ends with a / the server automatically converts that to /index.html.

For a bit of clarity, this behavior is due to the way modern web authors tend to serve content. Web servers as a whole are glorified file systems. A request to / is semantically equivalent to inspecting the root directory yet, because web servers have optimized the browsing experience in a manner such that they aren’t beholden to typical file system behaviors, that would-be directory request is often treated as a file request through some arcane redirection.