Skip to content
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

Closed
arendjr opened this issue May 12, 2022 · 115 comments
Closed

"module": "node16" should support extension rewriting #49083

arendjr opened this issue May 12, 2022 · 115 comments
Labels
Duplicate An existing issue was already created Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript

Comments

@arendjr
Copy link

arendjr commented May 12, 2022

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:

  • The file with the .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.
  • Using the .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.
  • Using the .js extension makes the source incompatible with Deno projects, as they use the .ts/.tsx extensions, because those are the ones that actually exist.
  • Using the .js extension is inconsistent with type import. Does import 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

  • I was unable to test this on prior versions because the feature is new to version 4.7.

🙁 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.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Out of Scope This idea sits outside of the TypeScript language design constraints labels May 12, 2022
@RyanCavanaugh
Copy link
Member

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)

@arendjr
Copy link
Author

arendjr commented May 12, 2022

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 import { myFunction, type MyType } from “./myFile.ts”, which I believe to be a very reasonable expectation from a user’s point of view, and you would emit that with the “.ts” extension intact, the resulting code would be invalid because that target doesn’t exist. You are building the compiler, you know that target doesn’t exist. So you wouldn’t be rewriting valid code, you would be rewriting code you know would be invalid if you didn’t rewrite it. I hope you’re not arguing it’s better to let the user shoot themselves in the foot.

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 .js extension. Compare this to the “facts” presented in the comment I linked, which clearly apply to a situation in which the user is free to choose whether they want to include the extension or not. This is not the situation as it is with the ESM resolution in TypeScript 4.7.

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.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented May 12, 2022

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.". import { foo } from "./x" is JavaScript code. Granted import { foo, type bar } ... is not, but I think it's clearly way too confusing to have one runtime path appear if you put the type keyword on a single named import and a different path if you don't.

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 import foo from "ba932c1fd3..." and then have a system to map that to foo.ts, not have you wrote import foo from "foo.ts" and then emit some JS other than what you wrote.

If you have some environment where you sometimes need to have JS that says import foo from "blah.js" and sometimes need to have JS that says import foo from "blah", that is also out of scope for our project, the same as if you needed the same input line of TypeScript to sometimes emit 3 * 4 and sometimes emit 3 + 4. There are tools to handle these problems and it's out of scope for us to re-implement solutions to those problems.

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.

@arendjr
Copy link
Author

arendjr commented May 13, 2022

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 node16 module resolution mechanism that comes with TypeScript 4.7. Unlike previous module resolutions supported by Typescript, this forces users to use the .js extension in their paths. While this may be a consequence of decisions that were made earlier, this does exacerbate the issues that result from those decisions. If nothing else, I believe that may present a valuable moment to reflect on those decisions to see if they still achieve the goals you set out to achieve.

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 .ts extension, in a resolution mechanism that follows the ESM resolution in spirit.

If TypeScript 4.7 is released with the node16 setting as it’s implemented today, I’m afraid this may introduce further schism between the various user groups. This is unfortunate, because node16 is presented as the way to do ESM with TypeScript, but for users to use it they will be forced to move away from their current practices in a manner that is incompatible with their current tools. It will become harder for a single codebase to support Node.js and bundlers, while there is no technical reason for this to be so.

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 import { myFunction } from “./myFile.ts” is valid TypeScript (even functional JavaScript under very strict circumstances) whose behavior would be broken if you do not rewrite the path during its emit phase.

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:

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 import foo from "ba932c1fd3..." and then have a system to map that to foo.ts, not have you wrote import foo from "foo.ts" and then emit some JS other than what you wrote.

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 node16 resolution as it stands further forces users towards. Instead, I hope that through a relatively simple change we can make people’s code more easily interoperable. Even if that change means a more fundamental reinterpretation of the original design goals.

@arendjr
Copy link
Author

arendjr commented May 21, 2022

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 MyType is not a class, but a TS type that gets erased during emit. What would the output be? It would become:

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 MyType refers to a TypeScript type, so you actually rewrote TypeScript rather than JavaScript. But in that case, please explain to me how the import path is different. “./myFile.ts” refers to a TypeScript file, so you would rewrite TypeScript rather than JavaScript.

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.

@mlandalv
Copy link

mlandalv commented May 22, 2022

It feels like many of these problems would be solved if TS enforced the use of file extensions in import/export paths. Not .js but the actual extension, eg .ts or .tsx.

Then it's up to the transpilation process to output code compliant with the specified module and target. If that means changing .ts to .js then so be it.

(What if tsc eventually gets support for a --out-file-extension=.mjs flag. Then those .js in the paths won't work anymore.)

@s123121
Copy link

s123121 commented May 25, 2022

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 .js file. On the top of my hat, to circumvent this problem, the developer need to:

  • Change eslint setting for missing import
  • Some tools need to reconfigured to resolve non-existent resource (like Deno, ts-loader, ...)
  • vscode can navigate between files just fine but what about other text editor

@neil-morrison44
Copy link

I think some of the issue stems from this

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.

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

@calebpitan
Copy link

calebpitan commented May 25, 2022

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.

@fabis94
Copy link

fabis94 commented May 25, 2022

I hope the TS team reevaluates their stance, the points made in the OP definitely make a lot of sense

@jakubmazanec
Copy link

jakubmazanec commented May 29, 2022

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 .js file.

As @s123121 said, the DX currently is horrible. If you're using anything else other than TSC for transpiling *.ts files, node16 is unusable, because other tools (e.g. Parcel, Jest, etc.) don't understand why are you trying to import non-existent file.

@unional
Copy link
Contributor

unional commented Jun 2, 2022

Another argument about file extension:

The purpose of the file extension is to explicitly state which file to resolve to.

In type: module, the CommonJS should have extension .cjs, while ESM have extension .mjs

Which means, if I am writing the code in TypeScript, and have two tsconfig to create those two outputs, then by definition tsc must rewrite the file path during compile time, regardless if I specify the file extension in TypeScript, and if I specify it as .ts, .cts, .mts, .js, .cjs, or .mjs.

Currently, the following is still not possible for TypeScript packages:

"exports": {
  "import": "./esm/index.mjs",
  "require": "./cjs/index.cjs"
}

@weswigham weswigham added the Duplicate An existing issue was already created label Jun 2, 2022
@weswigham
Copy link
Member

For anyone who stumbled upon this, if you want .cjs output files, use .cts input files, likewise if you want .mjs output files, use .mts input files. We've landed in a very nice place where there is a 1:1 mapping between input file extension and output file extension (excepting tsx), which is very very helpful for a number of aspects in tooling (like actually being able to find declaration files for arbitrary code without extra configuration).

Which means, if I am writing the code in TypeScript, and have two tsconfig to create those two outputs, then by definition tsc must rewrite the file path during compile time,

Keep your two tsconfigs with two outDirs, use .js extensions everywhere, in addition to a root package.json with type: module, put a sub-package.json in your esm output folder specifying type: module, and one in your cjs output folder specifying type: commonjs. Run your cjs build with module: commonjs, and your esm build with module: nodenext. No extension rewriting required, .js everywhere. The two separate builds are important because they're highly liable to typecheck differently - the esm and cjs modules will import different things (this is a feature in node, not our behavior), and writing a single source codebase that typechecks in both is actually incredibly difficult in practice, so I wouldn't recommend it. It's much easier to write a single source (mostly cjs but using module: nodenext) module and hand-write specific cjs and esm entrypoints as needed (or only write esm).

@unional
Copy link
Contributor

unional commented Jun 2, 2022

Keep your two tsconfigs with two outDirs, use .js extensions everywhere, in addition to a root package.json with type: module, put a sub-package.json in your esm output folder specifying type: module, and one in your cjs output folder specifying type: commonjs

Um... that doesn't work in practice, AFAIK.

I was planning to migrate my packages to type: module.

Originally, I was planning to have a clean cut of migrating from CommonJS to ESM.

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:

the esm and cjs modules will import different things

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 package.json doesn't really work, when you are talking about publishing to NPM,
unless you abundant the original package.

i.e. package-a -> package-a-cjs and package-a-esm.

@weswigham
Copy link
Member

weswigham commented Jun 2, 2022

Having two package.json doesn't really work, when you are talking about publishing to NPM,
unless you abundant the original package.

Nested package.json files within a package work fine? The sub-ones doesn't even have to contain anything other than the type field to provide resolution metadata for node itself. They're not for declaring two separate packages - they're for declaring the format for the .js files within those folders. There's still a single top level package.json for the package itself.

@weswigham
Copy link
Member

And make sure everything works (I have 100+ open source packages).

Then, they can be migrated to ESM only packages.

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.

@unional
Copy link
Contributor

unional commented Jun 3, 2022

There's still a single top level package.json for the package itself.

That's interesting. Maybe something I need to look into.

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.

Yes, definitely.

@arendjr
Copy link
Author

arendjr commented Jun 3, 2022

@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.

@weswigham
Copy link
Member

Of course the generic topic of rewriting import paths has been discussed before, but certainly not in this context.

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.

@arendjr
Copy link
Author

arendjr commented Jun 3, 2022

@weswigham I'm sorry, but it appears you do not entirely understand what we're asking for here.

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

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 esModuleInterop configuration option semantically alters how the code should be generated, yet you have no qualms with that.

(a semantic change that, as I've stated elsewhere, we could never get right 100% of the time thanks to dynamic import operations)

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.

If you really want omitting extensions to be valid node esm

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.

@mlandalv
Copy link

mlandalv commented Jun 3, 2022

If you really want omitting extensions to be valid node esm

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 .ts to work).

// 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.cts to be valid cjs until it's transpiled and obviously it's not since it doesn't use require. But still it works and transpiles properly.

// index.ts
import { sum } from './utils';

console.log(`CJS 1+2=${sum(1, 2)}`);

Just the same, I would expect

tsc --module CommonJS --outDir dist/cjs src/index.ts

to output valid commonjs, and

tsc --module NodeNext --outDir dist/esm src/index.ts

to output valid esm.

And I would gladly add .ts extensions if that made things less ambiguous.

Also it's quite unintuitive, or confusing, that package.json#type overrides/changes the behavior of tsconfig.json#compilerOptions.module. I thought there was a bug in tsc until I realized I had to use type = module to get anything other than commonjs (4.7.x). Byt type = module breaks lots of other stuff in my projects, so not really ready to change that just yet.

@jamesone
Copy link

jamesone commented Aug 26, 2022

Is there a way to get tsc to work with --experimental-specifier-resolution=node?

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?

@MicahZoltu
Copy link
Contributor

Sadly, the answer at the moment seems to be "don't use "module": "node16". 😢 Continue to compile with "module": "ES2022" and use something like a compile time transformer to add the appropriate extension.

@MicahZoltu
Copy link
Contributor

You're saying that if we added the ability to import types with dynamic import, we would start rewriting paths that we previously didn't?

I think the critical point in the decision of whether to rewrite a path or not is if the compiler both:
A. Resolves the path to a file on disk as part of its compilation process.
B. The compiler emits a file with a different path from the one that it resolved.

This means if someone did import { ... } from './apple.ts' the compiler would resolve ./apple.ts on disk, and then emit ./apple.js. In this scenario, the compiler knows what file it resolved and it knows that as part of its current execution it emitted a file with a different name to disk, thus it is reasonable for the compiler to emit the updated path anywhere the original path was referenced.

If import('./apple.ts') is encountered and the compiler resolves this dynamic import to a file on disk, and then proceeds to emit ./apple.js to disk, then the compiler should update the file with the dynamic import statement to the emitted path.

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 fs.readFile the compiler is not resolving the file, and it is not emitting anything, thus it should not make any changes to the path.

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 .ts suffix since we know that those will fail to resolve at runtime in all environments where TS is emitting files to disk (ts-node and deno don't emit anything to disk, so the path rewrite shouldn't occur).

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.

@davidmyersdev
Copy link

davidmyersdev commented Aug 29, 2022

If you just want the ability to use .ts extensions or exclude them altogether, you can use the paths compiler option to resolve your imports. This is the solution I decided to go with, because I need Node16 for subpath support. I use / as a root prefix, and I only resolve the .ts extension (when present) to .ts files.

{
  "compilerOptions": {
    "moduleResolution": "Node16",
    "paths": {
      "/*.ts": [
        "./*.ts"
      ],
      "/*.js": [
        "./*.js"
      ],
      "/*": [
        "./*.ts",
        "./*.js",
        "./*"
      ]
    }
  }
}

@andrewbranch
Copy link
Member

@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 "type": "module" in your package.json (and why or why not)? I’m currently writing a presentation/demo about #50152 and having more real-world stories is always helpful.

@davidmyersdev
Copy link

Hey @andrewbranch. The package with a subpath export is ink-mde, and it turns out the problem was actually in the package.json of that package. It was not properly configured to resolve the types for subpaths when using Node module resolution (cjs). Both projects (the linked library and my consuming app) use "type": "module" too, because they are both using ESM.

@alexandebryakin

This comment was marked as off-topic.

@saurabhsri108
Copy link

saurabhsri108 commented Jan 10, 2023

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.

@GabenGar
Copy link

@ibcoder001

Then in package.json, remove the type="module".

If your code doesn't work with this key, that just means your project doesn't emit proper ESM code.
Without this key there is no reason to set "module" other than "node" in the typescript config then, because ESM in that case is just a syntactic sugar for CJS.

@t-fritsch
Copy link

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 from "..." has always been meant to be a "compiled" one is counter-intuitive, even for vscode ("moduleResolution": "node") :

image

(Notice how vscode "thinks" that the from statement is refering to the .ts source file, not the .js compiled version)

@andrewbranch
Copy link
Member

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 .js extension, TypeScript still resolves to the .ts file, even though Node is going to resolve to a .js file.

I actually think we have the opposite bug—the completions here show .js once you start using a .js extension, even though we’re actually resolving a .ts file. I think this display should always show you the info TypeScript has under the hood, not just parrot what you write:

image

@t-fritsch
Copy link

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. .ts files).

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 ?

@RyanCavanaugh
Copy link
Member

and that this situation cannot be the long term 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.

@t-fritsch
Copy link

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 ?

@bakkot
Copy link
Contributor

bakkot commented Feb 17, 2023

@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 --no-emit plus allowImportingTsExtensions).

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?

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Feb 17, 2023

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 .ts extensions to .js (or add .js automatically) ?

A typical request goes like this:

When I write an import statement, I want to talk about the files as they exist as source files, not output files. So if I import * as x from "./foo.ts", TypeScript should understand that I want import * as x from "./foo.js" to appear in the output. A .ts import at runtime is never going to work in my environment, so it only makes sense to emit the .js version of that path. The same goes if I import something without an extension when, at runtime, an extension would be needed.

So why not just rewrite imports to .ts paths to .js?

The answer boils down to a few different reasons:

  • We believe single-file transpilation, or possibly even no transpilation, is the future of TypeScript
    • It's not possible to correctly change .ts to .js in a single-file transpilation
  • TypeScript's core language design principle is to preserve JavaScript as written

The Criticality of Single-File Transpilation

To define terms, single-file transpilation or single-file transpilability (SFT) is the idea that you can take a single .ts file and produce the correct type-stripped JavaScript without consulting a type system or other files on disk. I'll use "SFT" throughout this document to refer to this concept.

A good example of SFT compliance is type annotations. If a transpiler encounters

const x: number = y;

it doesn't need to know the meaning of number, where y came from, whether y is a valid number, whether number is a type that needs 32-bit integer truncation, whether a function in another file references x, or anything else. It can always correctly emit

const x = y;

A counterexample of SFT is merging namespaces across multiple non-module files, where the transpiler must know about the entire world of files in order to determine the correct emit for a single input file. If you have two files

// file1.ts
namespace A {
    export const p = 3;
}

// file2.ts
let p = 4;
namespace A {
    const b = p;
}

a transpiler cannot correctly emit file2.js unless it can see the entire contents of file1.ts, because the reference to p on line 3 of file2.ts could either be a reference to the outer let variable, or the cross-namespace access A.p.

The TypeScript flag isolatedModules turns on a number of checks that ensures your program is SFT-able, and it's a flag we recommend everyone turn on. We've also been careful to not add any new functionality that would cause new kinds of errors under isolatedModules to appear.

SFT has a number of extremely significant advantages:

  • In a CI environment, it's trivially parallelizable
  • Writing alternate transpilers is, for the most part, very straightforward if and only if the transpilation is SFT
  • In a live reload environment, SFT can easily be inserted into a "serve this output from these inputs" build chain without incurring a large additional latency
  • SFT is nearly configuration-agnostic, because it's only subject to a small number of configuration knobs which are straightforward to describe (target ECMAScript version plus which module system). A
    surprisingly hard problem is "Given this .ts file, which tsconfig(s) own it?", which can generally be dodged with SFT
  • Emit incrementality becomes trivial as well. Instead of needing to figure out which files need to be re-emitted if foo.ts changes, the answer is simple: by definition, only foo.ts needs to be re-emitted.

For many years, we've said that adding any "features" that break SFT is one of our biggest regrets, and we've consistently rejected new features in that vein. The proposal to rewrite module paths is one of them.

Meanwhile in the ecosystem, there are a huge number of very deservedly-loved tools that rely on being able to do SFT. Most large projects find it worthwhile to switch to a SFT-dependent build chain to improve their build times. And many of the near-term proposals around bringing "types to JavaScript" are also predicated on the ability for runtime platforms to do single-file transpilation as part of their normal execution.

In short, non-SFT TypeScript is vestigial at this point, and you should expect this trend to intensify. New compiler flags like verbatimModuleSyntax further enforce that "modern" TS will be SFT-compliant.

Resolution-based Path Rewriting is Non-SFT

Probably the largest intuitive leap here is the fact that path rewriting is not something that can be done in a single-file transpiler. It's not obvious why this is the case, so let's look at why.

The implicit request here is that if a module specifier ending in .ts resolves to a .ts file on disk, then the emitted .js file should refer to a file ending with .js instead. This behavior is non-SFT -- it depends on the overall project configuration, performing module resolution, and probing a bunch of paths on disk. It's also problematic to think about this behavior even if we want to abandon the idea of SFT. TypeScript supports an extremely large and varied configuration matrix when it comes to taking a module specifier and turning that into a set of candidate paths. For example, if you write

import * as x from "bar"

Then the type information for bar could come from:

  • A node_modules/bar/[anything] file anywhere on the directory spine
  • A bar/index.ts file found via the baseUrl of the project
  • Literally anywhere due to path mapping
  • Literally anywhere via symlinks
  • A completely unassociated declaration file containing a declare module "bar" { declaration
  • A file from node_modules/@types/bar
  • Probably more!

Critically, if you're writing a third-party transpiler for TypeScript, you don't need to care about any of this. You don't need to re-implement any of TypeScript's module resolution rules, at all, to correctly emit the semantically-same code as TypeScript. This is also important because you don't need to look at the disk at all, which vastly improves performance (a large part of TypeScript's pre-check startup time is spent resolving these modules on disk).

Generalizable SFT-compliant Solutions Don't Exist

Can we reframe the problem in a way that doesn't require SFT violation? Common ideas are covered below.

Yes, this would only be for relative paths, so it's actually simple

This isn't a sufficient rule - it fails to rewrite paths that would need rewriting, and rewrites paths that must not be rewritten. Either of those are deal-breakers.

Many projects (for example, VS Code) use monorepo-style mappings where files always import from a common nonrelative root:

import * as x from "our-project/subfolder1/subfolder2/some-file.ts";

Here, this isn't a relative path. If someone wrote some-file.ts, now to determine if that file has a .js output, a third-party transpiler would again need to correctly re-implement all of TypeScript's module resolutions, and be unable to do transpilation without consulting (many!) files on disk.

In the other direction, you also run into problems with directories. In node, for example, you can legally load ./foo.ts/index.js through the import require("./foo.ts"). In this case, rewriting "./foo.ts" to "./foo.js" would simply break what was previously a working program. For ES modules, no concrete specification of this behavior exists, so a relative ES import of a path may arbitrarily resolve to either a file or directory index file (remember that from the perspective of an HTTP client, no distinction even really exists here). The same problem exists with package export maps, which can present a path which looks like a filename (including ending in .ts!) that is actually an opaque, non-rewritable specifier.

The "relative paths only" rule fails to rewrite paths that need rewriting, and unavoidably rewrites other paths in a way that breaks working programs.

Could a different rule work?

Yes, this would be for all import specifiers ending in .ts, so no disk checking is needed, so it's actually simple

This isn't a sufficient rule - it still rewrites paths that must not be rewritten. Module specifiers already can legally end in .ts, and do. For example, you could be using this npm package named soundcloud.ts. If you wrote

import * as sc from "soundcloud.ts";

it is wholly ambiguous whether this is the npm module soundcloud.ts and needs to remain un-rewritten, or refers to [baseUrl]/soundcloud.ts and needs to be rewritten as soundcloud.js. Again, a third-party transpiler would have no way to emit this file correctly without consulting TypeScript's module resolution rules, as well as the disk. And gain, export maps mean that it's impossible to look at a path in a file in isolation and tell whether it should be .ts or .js in the final emit.

The "all paths" rule unavoidably rewrites paths in a way that breaks working programs.

Other rules can be tried here but they all fall victim to the same problem - without a comprehensive list of what files are on disk, and what the module configuration of the program is, there isn't a way to reliably distinguish between paths that must be rewritten and those that must not.

You can solve this with an enormous pile of configuration flags

Technically, yes, all problems can be solved with a sufficiently large configuration space. The question becomes, at that point, to what end? Today, if you want to write a working JavaScript program, the algorithm looks like this:

  1. Write the path that works at runtime (or at bundle-time)
  2. Inform TypeScript, through its configuration, how to turn those working paths into file resolutions
  3. If your program builds, you're done
  4. If not, iterate at step 2

In our hypothetical world of extension rewriting, the algorithm becomes:

  1. Write the path that is aesthetically satisfying
  2. Inform TypeScript, through its configuration, how to turn those working paths into file resolutions
  3. Inform TypeScript, through a completely orthogonal set of configuration, which paths should be rewritten and which shouldn't
  4. If your program builds, see if it runs. Otherwise go to step 2.
  5. If it doesn't run, try to figure out if you wrote the wrong path, had TypeScript resolve to the wrong path, or had the wrong rules about which path should be rewritten, or some combination thereof
  6. Modify one, or possibly two, or possibly all of the inputs in steps 1 through 3 to try to get a working program
  7. Go back to step 4 and hope you eventually reach a fixed point. It's possible no fixed point exists, or that the correct fixed point can't be reached from a series of single edits.

Preserving JavaScript as written

TypeScript's key language goal, perhaps the one single invariant we most strongly believe in, is that JavaScript code in your input should be left as-is during output.

To put it another way, ideally, the only thing that happens during the transformation from TS to JS is removing the type annotations from your files. We've stated that features like enums and namespaces were, retrospectively, mistakes in this regard.

The same is even true for allowing you to target different module systems from the input syntax to the output syntax, e.g. allowing ESM syntax to become CommonJS syntax. Doing it all over again, verbatimModuleSyntax would not only be the default, it wouldn't even be something you could opt out of. But when TypeScript was started over a decade ago, it seemed like the module ecosystem would either remain in pure chaos where no hard-and-fast rules would ever exist, or that the module ecosystem would settle into a few systems with developer-friendly interop rules. I would argue that neither has happened: ESM <-> CJS interop is very confusing, ESM behavior in the browser has no foundation of resolution logic upon which to build a reusable mental model, Node16 module resolution introduces a wide swath of new resolution primitives which are readily misinterpreted, and all of this is compounded by bundler interop rules which make many things that "shouldn't work" work, making the entire situation seem simpler than it is. So what seemed like a reasonable proposition in 2012, that you could write in a lowest-common-denominator import syntax and flexibly switch between output targets, has turned out to be a rather profound miscalculation.

That said, two wrongs don't make a right and we don't think the right way to fix this is to add as-yet-unmade mistakes on top of the ones we've already made.

Summary

As with everything else in TypeScript, our guiding principle is that you should write the JavaScript that you want to run, and add type annotations and configuration to tell TypeScript how to interpret that JavaScript in a way that gives you useful type checking.

Objections

Common objections at this point are addressed.

I can't multi-target CommonJS and ESM without this

Multitargeting is an advanced scenario and one that we think is outside our design parameters, per our longstanding design goals document. Much like how CJS -> Browser bundling has been a scenario we've long deferred to external tools like rollup/parcel/esbuild/webpack, taking the same codebase and producing both ESM and CJS is something we think community tools do an excellent job of, and we encourage you to use tools in that vein to accomplish this scenario. Spending our finite time making yet another implementation of retargeting functionality isn't the best use of a very limited resource -- we embrace the ecosystem for this sort of thing and don't feel the need to reinvent the wheel in a way that would ultimately fall short of these tools' excellent offerings.

If I can't write specifiers ending in .ts, then I can't use deno / bun / hyperflexibundler which do support (or require) .ts extensions

With TypeScript 5.0's module: bundler mode, you absolutely can write module specifiers ending in .ts. Notably, in all of these environments, no "rewriting" of paths ever occurs - TypeScript doesn't even emit in these scenarios. The idea that TS has to support rewriting .ts to .js in order to be used in scenarios where TS emit doesn't actually happen is a conceptual error.

This is unintuitive, and therefore wrong. Something must change.

We don't think it's intuitive either. But it would be folly to design something without keeping in mind a global view of the total complexity of the system. While it might be more intuitive at first to import from source paths, what happens next is the configuration morass described earlier in this document. It is acknowledged as frustrating that you have to write the path you would have written were you not using TypeScript. What we're trying to outline here is that this is maybe 2 or 3 units worth of frustration, compared to 30 or 40 units of frustration down the line were we to introduce a path rewriting system of any kind. There are a lot of subtleties here that are easy to miss, or sweep under the rug, or assume to be hypothetical, but we think they are real problems that would make this approach much worse than the status quo, as hard as that might be to believe.

@microsoft microsoft locked as resolved and limited conversation to collaborators Feb 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Duplicate An existing issue was already created Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests