microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.07k stars 12.49k forks source link

Generated ESM modules are broken. #54163

Closed magwas closed 1 year ago

magwas commented 1 year ago

Bug Report

🔎 Search Terms

I did not search. I fully aware that you do not regard it as a bug. Still please read my arguments carefully before you close this report as invalid, as it IS a bug: generates code which fails at runtime without warning, with all the information in hand to know it will fail.

🕗 Version & Regression Information

⏯ Playground Link

As it is about code generating behaviour, cannot be reproduced in the playground.

💻 Code


import { anything } from "nonrelative/module/or/one/not/with/js/extension"

🙁 Actual behavior

The whole point of a compiler is to make the life of programmers easier by hiding implementation details. The fact that the name of ESM modules end with '.js' and that they can only be imported relatively is such an implementation detail. Exposing Node's behaviour here is as correct as if in C I had to use includes with '.dll' extension on windows and '.so' or '.a' extensions on Linux depending on the runtime environment. In the source code, import refers to concepts existing in the abstraction level of source code (types is a good example), and the "from" part is to identify modules, which are may or may not files (often they are not). The esm module is object code from the perspective of the typescript programmer/compiler, a whole another abstraction level. I do understand that this decision of the node environment came because lack of proper namespacing solution, but that is their design decision (can be argued that not a good one: scoping all packages would be a decision actually addressing the root cause), it only binds Typescript to the extent that TS should implement that when generating an ESM module. Requiring source code modifications with the change of target object code beats the whole reason of existence of the compiler: hiding implementation details of the runtime environment.

The compiler have every information to generate relative imports with the js extension in the modules. requiring programmers to use relative imports with '.js' is not just failure to hide implementation details, but it is also manifestly incorrect.

🙂 Expected behavior

The generated ESM modules are useable as is.

GabenGar commented 1 year ago

In the source code, import refers to concepts existing in the abstraction level of source code (types is a good example), and the "from" part is to identify modules, which are may or may not files (often they are not).

What do you mean "often they are not"? Directory imports is a CommonJS meme made on assumption of having the modules on the same file system the runtime runs at. Which is absolutely not true for the browser javascript code. And there is no such thing as "directory" on the web.

The fact that the name of ESM modules end with '.js' and that they can only be imported relatively is such an implementation detail.

That's not an "implementation detail", that's literally how ESM modules are supposed to be referenced as per ECMAScript spec. Also you can use "exports"/"imports" fields in package.json and "baseUrl" with "paths" in tsconfig.json to alias the resolution paths to whatever you want, just don't forget to keep them in sync, since the former operates on the output paths, while the latter - on the input files.

To work around these problems, I have to unnecessarily bundle the code for unit tests.

Think you should treat your unit test addiction first, because you are using them in place of compiler. Unit tests (or specifically unit test frameworks) and ES modules compatibility is a trashfire right now, what do you expect from typescript to do there? Bending for the current frameworks means extra maintenance complexity and violation of the spec (they already struggle with it as is).

magwas commented 1 year ago

In the source code, import refers to concepts existing in the abstraction level of source code (types is a good example), and the "from" part is to identify modules, which are may or may not files (often they are not).

What do you mean "often they are not"? Directory imports is a CommonJS meme made on assumption of having the modules on the same file system the runtime runs at. Which is absolutely not true for the browser javascript code. And there is no such thing as "directory" on the web.

You have just provided examples of import not referring to files. I even did not think of those, I thought of npm modules. Anyways, these are examples supporting the "often they are not" expression. Thank you for supporting my argument.

The fact that the name of ESM modules end with '.js' and that they can only be imported relatively is such an implementation detail.

That's not an "implementation detail", that's literally how ESM modules are supposed to be referenced as per ECMAScript spec. Also you can use "exports"/"imports" fields in package.json and "baseUrl" with "paths" in tsconfig.json to alias the resolution paths to whatever you want, just don't forget to keep them in sync, since the former operates on the output paths, while the latter - on the input files.

The way ESM modules are supposed to be referenced in the runtime is about the runtime environment. It should have nothing to do with the source code, from that perspective it is an implementation detail, as they are in completely different abstraction levels. From the perspective of a typescript programmer/compiler, an ES module is object code.

To work around these problems, I have to unnecessarily bundle the code for unit tests.

Think you should treat your unit test addiction first, because you are using them in place of compiler. Unit tests (or specifically unit test frameworks) and ES modules compatibility is a trashfire right now, what do you expect from typescript to do there? Bending for the current frameworks means extra maintenance complexity and violation of the spec (they already struggle with it as is).

Please refrain from ad hominem. Unit testing is an important part of ensuring software quality, and it can be argued that whenever programming will be a profession - in the sense that a profession have its rules, and sticking to those rules makes sure that it is hard to screw up things - TDD/CDD will be among its rules of profession. I think it might prove beneficial to you if you work on why you needed to attack someone on the ground that they use different approach to their work than you.

I am using cdd-ts which is quite happy and fast with ES modules. (Yes, it is not strictly a unit testing framework, it is a Contract Driven Development framework.) The problem arose when I used it in an angular project: the angular compiler is not happy with putting '.js' at the end of imports (which is totally understandable), while tsc requires it for ES modules.

Josh-Cena commented 1 year ago

literally how ESM modules are supposed to be referenced as per ECMAScript spec

To be fair, the ES spec poses no restrictions on the module specifier format. For example, .ts imports works perfectly in Deno, and it could also work perfectly if you have an HTTP server that maps .ts requests to .js files, or transpiles TS on-the-fly and serves them with text/javascript MIME type.

I am using cdd-ts which is quite happy and fast with ES modules. (Yes, it is not strictly a unit testing framework, it is a Contract Driven Development framework.) The problem arose when I used it in an angular project: the angular compiler is not happy with putting '.js' at the end of imports (which is totally understandable), while tsc requires it for ES modules.

The only time when you absolutely need to use .js imports is if you are transpiling with tsc and then running it in Node. If you have a properly configured bundler/3rd party compiler that resolves .ts suffixes, you may want to turn on allowImportingTsExtensions. Remember that TS enforces that whatever gets produced is well-behaving runtime code with exactly the same JS semantics.

magwas commented 1 year ago

literally how ESM modules are supposed to be referenced as per ECMAScript spec

To be fair, the ES spec poses no restrictions on the module specifier format. For example, .ts imports works perfectly in Deno, and it could also work perfectly if you have an HTTP server that maps .ts requests to .js files, or transpiles TS on-the-fly and serves them with text/javascript MIME type.

Thank you for supporting it, but we are still talking about runtime implementation details. The runtime the code compiled to should not in any way influence the source code: the same source code should work with any supported runtime.

I am using cdd-ts which is quite happy and fast with ES modules. (Yes, it is not strictly a unit testing framework, it is a Contract Driven Development framework.) The problem arose when I used it in an angular project: the angular compiler is not happy with putting '.js' at the end of imports (which is totally understandable), while tsc requires it for ES modules.

The only time when you absolutely need to use .js imports is if you are transpiling with tsc and then running it in Node. If you have a properly configured bundler/3rd party compiler that resolves .ts suffixes, you may want to turn on allowImportingTsExtensions. Remember that TS enforces that whatever gets produced is well-behaving runtime code with exactly the same JS semantics.

  1. Again, these are implementation details of the runtime. They should not in any shape or form influence how source code should look like.
  2. The FAQ states that it follows node's behaviour. Which is in itself problematic because it is implementation detail, but it is a statement of support for node as a target. Given that ESM modules are (at least mostly) a node thing, I would expect the compiled module to work as is with node if there were not even warnings in compile time. I would not call it 'well-behaving runtime code'. And yes, probably the root of the issue is about the interpretation of TypeScript's strategic goals related to JavaScript compatibility. I elaborate on it below.
  3. Yes, I could (and now actually forced to) use a bundler/3rd party compiler. Because tsc and angular compiler disagrees on that '.js' extension. Which makes my development environment an order of magnitude more brittle and resource hungry. ( The workaround to achieve bearable compile times was to run an esbuild process in watch mode for each of the contracts. It means a lot of processes which eat up a lot of resources, hard to control, and whenever I make a new contract, the whole thing should be restarted.)

Regarding ypeScript's strategic goals related to JavaScript compatibility:

It is an absolutely understandable goal of TypeScript to build on JavaScript legacy, and make it easy to convert existing JS codebases to TS. It obviously means that anything what works as javascript should work as typescript as well. But it is still another language. Right at the point you use '.ts' instead of '.js' you made changes which affect behaviour. (And honestly there is no way to fully support all quirks of that whole sad mess JS with its uncountable dialects and module formats became, but that is beside the point.) And nothing in those goals stops you to make changes which result in code which is not a working javascript code in the first place to be compiled into working javascript modules. I believe I am not the only one who have choosen TypeScript because besides JS it is the only mature ecosystem for the web, and wanted to work with something which resembles to a decent language. And I do believe that after a point (when most of JS coders who are not overly attached to the swamp they are sitting in right now already converted) this will be the main adoption force for TypeScript. For us there are a couple of pain points caused by the JavaScript legacy. I understand that dealing with some of them is hard, and would result in incompatible changes. But one of those - into which I personally run into like every two weeks, each time spending at least a day to work it around - is that sad mess of module formats. And nothing stops TypeScript to say that 'okay, here is a reasonable default syntax for imports. If you use it, we make sure that whatever module format we compile into, it will work.' And actually we already have switches for cjs and esm interoperability, so somehow this general topic have already proven to be enough of a pain point to address it.

(In an ideal world there would also be a configuration option - turned off by default for interoperability - to have a 'self' keyword, which would be the alternative of 'this' which would always hold the instance of the class it is used in. Even that could be done in a way which does not break backward compatibility, and would address another big pain point. But it is also besides the point here.)

GabenGar commented 1 year ago

You have just provided examples of import not referring to files. I even did not think of those, I thought of npm modules. Anyways, these are examples supporting the "often they are not" expression. Thank you for supporting my argument.

I did not support your argument in any way, npm imports come down to parsing a package.json file (even if the process is riddled with side effects with tons of http connections and file system lookups). And it just happens this tight coupling assumption for module specifiers is also a problem both for NodeJS and other javascript runtimes. So there is no such thing as "non-file" imports outside of specifics of CommonJS (which is not compatible with ESM on fundamental level), a nodejs-specific module system.

The way ESM modules are suppose to referenced in the runtime is an impementation detail of the runtime environment. It should have nothing to do with the source code. They are completely different abstraction levels.

ESM is not an "implementation detail" even if you want to believe it so.

Please refrain from ad hominem.

Your post is clearly opinionated and fueled by personal feelings and not well researched facts. You basically treat typescript, a mere superset of javascript, as a magical GIRCO (Garbage In Right Code Out) compiler which will do everything for you short of deploying the project in the cloud.

Unit testing is an important part of ensuring software quality, and it can be argued that whenever programming will be a profession - in the sense that a profession have its rules, and sticking to those rules makes sure that it is hard to screw up things - TDD/CDD will be among its rules of profession. I think it might prove beneficial to you if you work on why you needed to attack someone on the ground that they use different approach to their work than you.

So you accused me of ad hominem, then followed with a typical vague cultish TDD boilerplate and then used a passive-aggressive ad hominem on me. Nice try, but only proves my point of you being addicted to unit tests, especially since you considered my friendly advice as a personal "attack", a typical trait of an addicted person by the way.

I am using cdd-ts which is quite happy and fast with ES modules. (Yes, it is not strictly a unit testing framework, it is a Contract Driven Development framework.) The problem arose when I used it in an angular project: the angular compiler is not happy with putting '.js' at the end of imports (which is totally understandable), while tsc requires it for ES modules.

This is clearly an angular problem not knowing what ESM is, how exactly do you expect Typescript to solve your third-party dependency problem?

The core issue is you think ESM is some sort of a fad you have to follow in order not to be seen as a dinosaur. However it stopped being a fad the moment it became stable in NodeJS, ESM is not a mere syntactic sugar for bundlers anymore. So you are quite late to the party. In order for typescript to output proper ESM code, you'll have to learn to write proper ESM code as an input. Either that or stick to writing CommonJS code, it will have an additional benefit of having full typescript's GIRCO (Garbage In Right Code Out) compiler support and angular will be happy too.

magwas commented 1 year ago

What is so hard to understand in the fact that source code and object code/runtime are completely different levels of abstraction?

I do understand that the difference between ad hominem and proper nonviolent communication is beyond the scope of technical expertise, so I won't elaborate on that.

GabenGar commented 1 year ago

@Josh-Cena

To be fair, the ES spec poses no restrictions on the module specifier format.

It certainly does, a module path string has to resolve to a valid ECMAScript module eventually. That there can be a server which can map an arbitrary path to another path on its file system is just a feature of ES Modules due to their async nature. Outside of running a server which does that, the mapping has to be provided beforehand. I think ECMAScript even has a built-in way to do that, but it didn't look very stellar, especially in async context with unpredictable resolution order, possibly mutable mapping and cross-origin environments. At least package.json/tsconfig.json mappings are stored in known places with almost no runtime implications.

@magwas

What is so hard to understand in the fact that source code and object code/runtime are completely different levels of abstraction?

What do levels of abstractions have to do with you being unable to understand semantics of ESM? Besides, your problem lies in the same level of abstraction anyway (source code transpilation), you just have a config problem.

magwas commented 1 year ago

What do levels of abstractions have to do with you being unable to understand semantics of ESM?

I do understand semantics of ESM, but because of the different level of abstraction it should not have any impact on how the source code look like. Source code and object code are on completely different abstraction levels.

fatcerberus commented 1 year ago

There is no “object code”; TypeScript is, by design, JS with type annotations. So you write the import that works in your JS runtime environment, as-is, and configure TS accordingly. Imports are no different from the rest of your TS code in this regard—what you write in TS is what the JS runtime sees, modulo type information which is erased.

fatcerberus commented 1 year ago

Re: The DLL file analogy - import should be thought of as more of a LoadLibrary/dlopen than an #include.

Josh-Cena commented 1 year ago

I think ECMAScript even has a built-in way to do that

I'm not aware of one and I'm very happy to be corrected. The module specifier, AFAICT, is completely opaque to the ES spec and is a host concern. The fact that "specifiers have to map to modules" is immaterial as we are talking about how this mapping should be provided, given that an algorithm exists to unambiguously point it to a module. If TS turns out to be a host of ECMAScript, then indeed TS can allow .ts imports. The only reason why it doesn't do that is because TS is not a host, just an extension syntax. It doesn't alter any runtime semantics imposed by the actual host, let it be the browser, Node, or Deno. It tries to enforce whatever invariants the runtime requires, such as explicit .js extensions.

RyanCavanaugh commented 1 year ago

You have a project misconfiguration. Unfortunately you haven't provided us enough details for us to be able to diagnose exactly what the misconfiguration is, but rest assured your project is simply misconfigured and in a correct configuration you would receive a proper build time error here as is expected.

See also https://github.com/microsoft/TypeScript/issues/49083#issuecomment-1435399267

Hundreds of Github comments have been rehashed on this issue over and over again. Import paths are not modified during compilation, and we're not going to modify them during compilation, and it isn't because we haven't thought about it before and just need to spend another hundred comments arguing about it.

The general tenor of discussion here is not great and I don't think this is likely to lead to any further productive discussion, so I'm just going to lock this.