-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
"module": "node16"
should support extension rewriting
#49083
Comments
We don't rewrite target-legal JavaScript code to be different JavaScript code from what it started with, no matter the configuration. See also #16577 (comment) |
I’m sorry, but I cannot understand why you would label this issue as Out of scope or why you would dismiss this based on a comment from almost one and a half year ago. You argue “we don’t rewrite target-legal JavaScript code”. But this argument does not apply. If someone wrote Furthermore, the comment you linked depends on the arguments presented in yet another comment: #16577 (comment) If you read the arguments there, this is clearly a different situation than what we are facing today. My issue is with the change in the new module resolution for ESM introduced in TypeScript 4.7. This change forces users to specify the Back then, users could improve compatibility by adding the extension, but the way they are now forced to use it compromises compatibility as I attempted to explain, but none of which you addressed, and none of which any of the linked comments address. As such I would kindly ask you to reconsider the implications of the new module resolution, including the impact on developers and the wider ecosystem. |
I don't think there's anything new to discuss here; the strong line we've drawn in the sand is that we do not rewrite import paths, ever. Our design principle, consistently, is that you write the import path that you want to appear in your JS file, and configure TS to tell it how to turn that path into a build-time path. Doing it differently is out of scope for our project. This is a hard constraint per design goal number 7, "Preserve runtime behavior of all JavaScript code.". Nothing about node16 resolution changes this. Our principle even would be the same if some new module resolution system appeared where you wrote the sha256 hash of the file you wanted -- we would have you write If you have some environment where you sometimes need to have JS that says There are surely difficulties in navigating the module ecosystem, no one can deny that. What I'm saying is, rewriting import paths is not a solution we consider to be in-scope. We're always interesting in hearing ideas that can simplify the module resolution process and improve the user experience as long as it fits within our design criteria of not rewriting import paths. |
Thanks for your reply. I understand I’m getting at some pretty fundamental issues here, so I try to navigate this as delicately as I can. First, the new thing I mentioned is the I also believe the timing is important here. So far, and as far as I’m aware, Typescript users have mostly avoided including extensions in their paths. And for good reason: Including them makes their code less compatible with existing bundlers used in the ecosystem. One notable exception here are Deno users, who include the If TypeScript 4.7 is released with the So while there’s no technical reason for this outcome you admit to be unfortunate, you do present one based on principle: You say you “do not rewrite import paths, ever” and back this up with the design goal that says to "Preserve runtime behavior of all JavaScript code." So let’s reflect on this. Because if these principles/design goals will inevitably lead us to face such an unfortunate outcome, is it possible the design goal itself is in need for reconsideration? I would not necessarily go that far, but I do think your proverbial line in the sand to “not rewrite import paths, ever” is an unnecessarily strict interpretation of the design goal. Let’s revisit my original request, and let’s simplify it to focus on the core issue: import { myFunction } from “./myFile.ts” Is this valid JavaScript code? It’s certainly syntactically valid. But if “./myFile.ts” does not exist or contains syntax that is not valid JS, it’s certainly not functional JavaScript code. Does it make sense to wish to preserve behavior of non-functional code? But let’s take it a step further: How is this code supposed to behave? Again I will refer to Deno, because it defines actual runtime behavior for this that is inline with the ESM specification in spirit. But here’s the rub: Deno provides a TypeScript runtime, not merely a JavaScript one. So this code is valid TypeScript code, even if it is not functional JavaScript code. Let’s look at that design goal one more time: "Preserve runtime behavior of all JavaScript code." The behavior is clear. Just run it in Deno and see. But interestingly, it is the TypeScript code emit that breaks this behavior! Because after the code emit, the file it referred to is no longer there. I know you will defend this by saying the path is intended to be an output path, but how does that follow from your design goal to not break behavior? If anything, this intention feels more like a post hoc rationalization to defend the “do not rewrite import paths, ever” position than it seems a useful part of the design. It’s certainly not a design goal. Now, I realize there are no easy answers here. But from my perspective, the design goal to "Preserve runtime behavior of all JavaScript code" would be served by rewriting import paths if the behavior would otherwise be broken by your own emit process. The statement While it is your prerogative to define the scope of your project, in my opinion it is a stretch to claim an issue introduced by your own process (it’s inevitable that paths break when you write files to a new location with new names) as out of scope. Finally, I would like to come back to the hypothetical example you raised:
What I find interesting here is that you say you’re willing to build a system to do the mapping, but you’re not willing to help the user writing the code. It seems you hold stronger to the notion of not rewriting import paths than to preservation of behavior or the notion of creating value for your users. We both have the same design goal in mind (to maintain the behavior of the code), but to me this shows your “do not rewrite import paths, ever” position is untenable and it feels not in line with what I believe would be the best interests of the project. I hope I have not offended with this post. I raised this issue because I believe it would be harmful to the TypeScript community if segments of the community have to use incompatible module resolution schemes, which the |
As I fear this conversation might otherwise go entirely stale, I would like to offer one more argument as to why I think the “do not rewrite import paths, ever” position is not just undesirable, but actually runs counter to your own design goals. Let’s look at another variation of the example we discussed so far: import { myFunction, MyType } from “./myFile.ts” Let’s assume import { myFunction } from “./myFile.ts” That’s interesting, isn’t it? The original snippet was 100% syntactically correct JavaScript. And yet it was rewritten as part of the emit process. Of course rewriting it was the correct thing to do. Without rewriting, the emit process would have yielded output the JavaScript runtime would have trouble resolving. So we must rewrite JavaScript in order to preserve behavior. Please explain to me how the import path is different. Sticking to “do not rewrite import paths, ever” is leading to output the JavaScript runtime will have trouble resolving — the exact same behavioral issues that you are willing to solve elsewhere. Issues you must solve in order to adhere to your own design goal of preserving behavior. Of course you could argue that Rewriting paths to TypeScript files in order to preserve behavior is compatible with your original design goal. To “not rewrite import paths, ever” is not. |
It feels like many of these problems would be solved if TS enforced the use of file extensions in import/export paths. Not Then it's up to the transpilation process to output code compliant with the specified module and target. If that means changing (What if |
Technical aside, this issue make the DX worse for everyone. It doesn't make any sense for relative import to refer to a non-existent
|
I think some of the issue stems from this
as a user I see the typescript compiler taking valid TS code from the “src” realm and converting it to valid JS code in the “build” realm. When I first heard about the “.js” extension it broke what I understood to be the separation between “src” & “build” - I wondered if I’d need to supply the full path to the output .js in the source file, or otherwise adjust it based on my knowledge of where the .js file would end up |
If only there was a way to put this to vote! @RyanCavanaugh’s argument, which I believe represents that of the TypeScript team, doesn’t seem sustainable on the long run. If a community of other engineers says this is what they think is best, then it better be given a strong consideration, rather than being labeled “out of scope” just for the sake of wanting to be conservative. Except TypeScript was made only for the TypeScript team. The system is changing! If need be that some principles are reviewed as the system changes, to better support and adapt these changes then so be it. Otherwise the system shouldn’t change and fundamental principles be kept. Moreover, @arendjr’s arguments in summary has not even changed the principles it has only broadened the short-sighted interpretations and undue strictness of these principles. Oh and I think it is being put to vote already seeing how many reactions are yet in favor of @arendjr’s opinion. |
I hope the TS team reevaluates their stance, the points made in the OP definitely make a lot of sense |
As @s123121 said, the DX currently is horrible. If you're using anything else other than TSC for transpiling *.ts files, |
Another argument about file extension: The purpose of the file extension is to explicitly state which file to resolve to. In Which means, if I am writing the code in TypeScript, and have two Currently, the following is still not possible for TypeScript packages:
|
For anyone who stumbled upon this, if you want
Keep your two |
Um... that doesn't work in practice, AFAIK. I was planning to migrate my packages to Originally, I was planning to have a clean cut of migrating from i.e., from: {
"main": "./lib/index.js"
} to: {
"exports": {
"import": "./lib/index.js"
}
} I completely subscribe to that idea, and understand that is the way to go as:
But what I have found is that when I publish the package like that, it breaks everywhere. Because of the interconnected dependencies and the lovely node module resolution algorithms. So the migration needs to be done in two phases: First, migrate to: {
"exports": {
"import": "./esm/index.js",
"require": "./cjs/index.js"
},
"main": "./cjs/index.js"
} And make sure everything works (I have 100+ open source packages). Then, they can be migrated to ESM only packages. Having two i.e. |
Nested package.json files within a package work fine? The sub-ones doesn't even have to contain anything other than the |
Dropping cjs support is still definitely going to be a major-version-bump change, even if adding dedicated esm entrypoints can be done in a minor. |
That's interesting. Maybe something I need to look into.
Yes, definitely. |
@weswigham Could you please elaborate on the Duplicate label you added? I see no reference to any issue this would be a duplicate of, nor am I aware of any such issue. Of course the generic topic of rewriting import paths has been discussed before, but certainly not in this context. As an aside, I sorted your issues by number of upvotes and it appears that in three weeks time, this issue has become (with a wide margin) the most upvoted issue of the past year. If this were truly a duplicate, it seems the original was not resolved well. |
Duplicate of #16577 - "this context" doesn't really meaningfully change anything - if anything it makes rewriting extensions even less appealing by the logic we've stated before, since now we have multiple input extensions that already mean multiple output extensions. We're not trying to somehow make node's new behaviors more paletable for people - that's node's fault, not ours. Our philosophy has been, and will be, that we don't semantically change your code during emit, and rewriting imports most certainly is a semantic change (a semantic change that, as I've stated elsewhere, we could never get right 100% of the time thanks to dynamic import operations). If you really want omitting extensions to be valid node esm, (as it is in cjs) I suggest a complaint about the new node behavior to require extensions over at nodejs/modules#323 or on the node repo proper, rather than here, which is pretty much invisible to the node maintainers - as whatever node supports, we will follow suit. |
@weswigham I'm sorry, but it appears you do not entirely understand what we're asking for here.
If that it is truly your philosophy, you've surely been very selective at following it. Rewriting ESM to CommonJS by itself is a semantic change, yet you have no philosophical issues implementing it. The
We're not asking for dynamic imports to be supported here. Raising that as an issue is a strawman argument, because dynamic imports themselves are fundamentally different from static imports, and we can understand those to be out of scope. Hence why that's not what's being asked for here.
Once more, I'm not asking for the omission of extensions, I'm asking for the rewrite of extensions in the exact way that is compatible with how your build process already rewrites extensions. This is not a duplicate of the issue you reference, it's a different request. |
I'm not sure anyone is asking for this. We're talking typescript and at least I don't consider my TS code to be valid esm/cjs until it's transpiled. Sorry deno land folks (although I would expect // utils.ts
export const sum = (a: number, b: number) => {
return a + b;
};
// index.cts
import { sum } from './utils';
console.log(`CJS 1+2=${sum(1, 2)}`); I'm not considering // index.ts
import { sum } from './utils';
console.log(`CJS 1+2=${sum(1, 2)}`); Just the same, I would expect
to output valid commonjs, and
to output valid esm. And I would gladly add Also it's quite unintuitive, or confusing, that |
Is there a way to get ts-node supports this flag. However, ts-node does not provide compiled javascript output. I'd rather not have to refactor my entire codebase to add extensions to every import. Anybody have any way around this? |
Sadly, the answer at the moment seems to be "don't use |
I think the critical point in the decision of whether to rewrite a path or not is if the compiler both: This means if someone did If FWIW I don't actually know whether the compiler resolves dynamic imports to disk or not as I don't tend to use them, so this suggestion is coming from the point of view of someone who has no prior expectations of behavior in this regard but this is the behavior I would expect and find reasonable from a compiler that is both resolving files and emitting new files based on those resolutions. In the case of I feel like this strategy would help draw a bright line and prevent the slippery slope that @RyanCavanaugh appears to be concerned about (I'm generally sympathetic to slippery slope arguments, but if a strong Schelling Fence can be found it can often help mitigate the risks of such slopes). FWIW: I would be totally fine with this feature going in along side #37582 and perhaps even only affecting files with an explicit FWIW 2: I also understand that implementing the strategy described above may be hard and perhaps low priority, but at the moment the argument being made is that this entire idea is bad and should never be implemented which is why I continue to periodically try to sway the TS dev team on the topic. I do very much appreciate the honesty of the TS dev team though, and their willingness to say why they are choosing not to implement this, despite the negative feedback it generates from the community. |
If you just want the ability to use {
"compilerOptions": {
"moduleResolution": "Node16",
"paths": {
"/*.ts": [
"./*.ts"
],
"/*.js": [
"./*.js"
],
"/*": [
"./*.ts",
"./*.js",
"./*"
]
}
}
} |
@voracious can you tell me what runtime/bundler you are writing code for, and what package(s) you’re using that require subpath export support? Also, whether you’ve set |
Hey @andrewbranch. The package with a subpath export is |
This comment was marked as off-topic.
This comment was marked as off-topic.
What a disaster of a decision. Everything is messed up due to this decision based on "philosophy" rather than practical technical reality. Anyways, here's what I did. Maybe it was resolved or at least for now it works for my use cases: {
"compilerOptions": {
"target": "ES2017",
"module": "Node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noImplicitAny": true,
"outDir": "./dist",
"resolveJsonModule": true
}
} Then in package.json, remove the type="module". This kind of works for my use case express app. Don't know about other cases if it works or not. |
@ibcoder001
If your code doesn't work with this key, that just means your project doesn't emit proper ESM code. |
I'm quite new to TypeScript, and found this issue really troubling. I do understand there are some edge cases, but the argument that the path you write in (Notice how vscode "thinks" that the |
We can make whatever icon and detail text we want show up there. We know that at runtime, import paths always need to resolve to JS files, but making every icon say JS doesn’t feel very useful. It says TS because TypeScript was able to resolve a TS file to give you typings. When you write a I actually think we have the opposite bug—the completions here show |
thanks a lot @andrewbranch for your quick reply 🙏 In fact I was pointing the inconstistency between what TS team seems to find "obvious" ("TypeScript has always used output paths for specifier resolution") and what average users may expect (when you're editing source files, your imports "from" path should reference source files, ie. You agreeing that this is totally normal and fine that vscode hints "myModule.ts" when importing without extension, makes me think that maybe you could agree too that the frustration exposed by developers here (and on many node.js issues too...) is quite legit, and that this situation cannot be the long term solution ? I don't know whose responsibility it is, there seem to be quite a lot of "friction" between the TS and Node teams on this subject from what I read, but is there any hope that you guys try to find a solution ? |
This presupposes that introducing a new axis of complexity, wherein people have to reason about both the module specifier resolution and its output transform, and all tool authors have to agree exactly on what those transform rules are, would be better. We've thought about this a lot and don't think that it would be. |
thanks @RyanCavanaugh but this doesn't answer my question, or maybe I should read between the lines Correct me if I'm wrong but your point here is "it would add a new axis complexity to the tool (ie. TS compiler)" and this is a reason solid enough to add "a new axis of complexity to every development made with the tool" for developers using TS and Node ? And because this is a reason solid enough in your point of view you do think this is a satisfying long term solution ? If I am right, do you at least understand how this is counterintuitive + frustrating for ppl using TS or not at all ? Don't you think that providing a way for people using TS + node ESM to import the same way you allow bundler users to do (ie. using .ts extension for imports) would be a huge benefit for your users ? |
@RyanCavanaugh It's true that right now you only need to think about "module specifier resolution", but the problem is that module specifier resolution is extremely confusing, because you are required to refer to files which don't exist (except when using My belief is that making the it easier to reason about module specifier resolution (by removing this absolutely wild fact you need to know even in the most common case) would more than make up for the very small additional amount of complexity required to reason about the module specifier transformation (i.e., the file name in the specifier is rewritten in exactly the way the compiler is already rewriting the file name on disk). Above people mentioned that there's some edge cases with projects like vscode which do more unusual things, such that it might be a net increase in complexity for those projects. I accept this, though I haven't seen examples where it actually seems more complicated to me. But I think this is more than outweighed by the easier load on beginners and people doing simpler things. Do you disagree? If so, is it because a.) you think the current situation is already intuitive for beginners, b.) because you think the proposal here would not be more intuitive for beginners, or c.) because you think the increase in complexity for non-beginners outweighs the benefits for beginners? |
I don't have a good place to put this, but this is gathering a lot of drive-by support requests and seems to be where people are ending up to bring up the issue again. I'm going to lock for clarity and post the below explanation as to why. TL;DR: We are not going to change import specifiers during emit. It's not because we haven't considered it. Please log a new issue if you have encountered a bug or configuration problem where you are unable to make module resolution work like you want it to. Why Not Rewrite
|
The thing I said would never happen has happened 🙂 See #59767 |
Bug Report
TypeScript 4.7 RC introduces the
"module": "node16"
setting. When used, this requires users to use the.js
extension in import paths. I strongly believe this to be a mistake and TypeScript should instead allow users to use the.ts
/.tsx
extension of the source file. It would then be the TS compiler’s responsibility to rewrite this to the output file as necessary.I have several reasons for requesting this change:
.js
extension most likely does not exist on disk. It may exist on disk if a build has been created and the output is placed right alongside the sources (not a common configuration), but otherwise the file is simply not there or in a different place than one might expect. This is confusing to users, because they need to manually consider how output files are created..js
extension makes the source code less portable for full-stack projects. If a user wants to use the same sources for a frontend project that uses a bundler, they will be out of luck. A bundler would be able to understand a reference to the.ts
file (because that one actually exists on disk!), but would struggle with references to files that do not exist for its configuration..js
extension makes the source incompatible with Deno projects, as they use the.ts
/.tsx
extensions, because those are the ones that actually exist..js
extension is inconsistent with type import. Doesimport type { MyType } from “./myFile.js”
even work? It would not make sense because the JS file contains no types. But if it doesn’t work, does that mean I still have to use the.ts
extensions only for the types? That would be highly annoying.🔎 Search Terms
extension rewriting esm module
🕗 Version & Regression Information
TypeScript 4.7. RC1
🙁 Actual behavior
I am forced to write
import { myFunction, type MyType } from “./myFile.js”
despite “./myFile.js” not being a valid path in my project.🙂 Expected behavior
I should be able to write
import { myFunction, type MyType } from “./myFile.ts”
because “./myFile.ts” is the actual file I am referring to. Upon compilation I would expect TypeScript to rewrite the path so the output works correctly too.The text was updated successfully, but these errors were encountered: