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

Feature Request: allow change file extension of generated files from .ts #49462

Open
5 tasks
bluelovers opened this issue Jun 9, 2022 · 29 comments
Open
5 tasks
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@bluelovers
Copy link
Contributor

Suggestion

🔍 Search Terms

List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily and help provide feedback.

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

add something like

{
  "compilerOptions": {
    "module": "nodenext",
    "targetExtension": ".cjs",
}

📃 Motivating Example

  • when targetExtension is .cjs , all .ts will emit as .cjs, but .mts still is emit as .mjs
  • when targetExtension is .mjs , all .ts will emit as .mjs, but .cts still is emit as .cjs

💻 Use Cases

@IllusionMH
Copy link
Contributor

What are exact use cases and what problem it should resolve?
From first look TS would also need to rewrite extension in generated imports that in no go at this moment #49083 (if I haven't missed anything).

@Josh-Cena
Copy link
Contributor

Major use-case is building dual-package with tsc without any postbuild script, I think.

@milesj
Copy link

milesj commented Jun 10, 2022

You shouldn't be dual building packages anyways (use a wrapper), so I prefer the .cts/.mts constraint that TS indirectly enforces.

@bluelovers
Copy link
Contributor Author

i don't wanna make files of .cts and .mts they are same context
so i wanna one .ts can be .cjs and .mjs

@azu
Copy link

azu commented Jun 11, 2022

I've met same issue when I building dual package.

e.g. https://github.com/azu/check-ends-with-period/tree/v2.0.0 (It is invalid example as dual package)
TypeScript source code is insrc/*.ts and package.json has "type": "module" field.
Also, this repository has two tsconfig files.

I've defined exports field as follows, but this package was treated as ESM because "type": "module" is defined.
As a result, This package can not be requred from CJS without dynamic import.
(Node.js treats *.js file as ESM by "type": "module")

  "exports": {
    ".": {
      "require": "./lib/check-ends-with-period.js",
      "import": "./module/check-ends-with-period.js"
    }
  },

I could not found just works solution without using bundler/post scripts.


If targetExtension option exists, I can resolve this issue by tsconfig file.

  • tsconfig.json + "targetExtension": ".mjs",: generates esm to module/*.mjs from src/*.ts
  • tsconfig.cjs.json + "targetExtension": ".cjs",: generates cjs to lib/*.cjs from src/*.ts

However, this option will need to rewrite import path of source code. It oppsite TypeScript's design goal.

...

Edit(2023-01-14): I've created tsconfig-to-dual-package for avoiding this issue.
This tool add package.json which is { "type": "module" } or { "type": "commonjs" } based on tsconfig's module and outDir option.
In other words, publish *.js as CJS and ESM in a single package.
This mechanism based on following:

@milesj
Copy link

milesj commented Jun 12, 2022

Might I suggest packemon: https://packemon.dev/

Also, why exactly are you dual building? You run the risk of the dual package hazard: https://nodejs.org/api/packages.html#dual-package-hazard It's better to use an ES module wrapper.

@bluelovers
Copy link
Contributor Author

i think this only work on nodejs
does browser support it?

// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;

@milesj
Copy link

milesj commented Jun 13, 2022

Browsers don't support .cjs/.mjs natively, unless it gets bundled through webpack or a similar tool to .js, and at that point, why even use .cjs/.mjs for browsers?

@Josh-Cena
Copy link
Contributor

Josh-Cena commented Jun 13, 2022

The idea is to use native modules when targeting browsers without any transpilation/bundling, and optionally CommonJS when targeting Node.

@milesj
Copy link

milesj commented Jun 13, 2022

Yes of course, but not if you're using .mjs. At least in @azu's example, their ESM code should be shipped to the browser with .js, and CJS code to Node.js with .cjs (or even just .js too).

We also just need more information, as we're making many assumptions here. The original post doesn't contain much.

@Josh-Cena
Copy link
Contributor

Josh-Cena commented Jun 14, 2022

Wouldn't we be emitting .cjs + .js (+ type: module) when building dual-package purely for Node anyway? It's usually unnecessary to have explicit extensions for both sets of module types. Also, browsers can handle .mjs as well, as long as the MIME type is JavaScript. But to author dual-package of any kind, whether targeting Node or browser, we need at least one subset of the output to have an extension different from the other, and that would require TS to be configurable about this. But I agree we lack some context here.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Jun 20, 2022
@alvis
Copy link

alvis commented Jun 21, 2022

I'm pro @bluelovers's suggestion. The use case for me is publishing dual ESM/CommonJS packages for nodejs.

There are many reasons why we need to publish dual ESM/CommonJS packages. For instance, not until 4.7, TS doesn't even allow a node application in commonjs to consume a pure ESM library due to the lack of support on await import(...). So I really find it annoying some package authors publish pure ESM packages.

@cefn
Copy link

cefn commented Dec 4, 2022

@milesj the use of an ESM wrapper around CommonJS defeats tree-shaking, doesn't it? Given ESM has broken the whole ecosystem to try and achieve results like tree-shaking, having the recommended way to align with ESM being to throw away its core features is disappointing. Correct me if there is a reasonable way to get both. My expectation is to follow the principle that modules should be stateless - a good practice I don't ever find the need to violate. Then I understand there are no concerns with dual building.

@milesj
Copy link

milesj commented Dec 4, 2022

If you have a dual package, and some other package in CJS context requires your package, and another package in ESM context imports your package, you'll end up with 2 copies of your package. For node this doesn't matter too much unless there's some kind of global/shared state, but for bundlers this is bad.

@gfortaine
Copy link

I've met same issue when I building dual package.

e.g. https://github.com/azu/check-ends-with-period/tree/v2.0.0 (It is invalid example as dual package) TypeScript source code is insrc/*.ts and package.json has "type": "module" field. Also, this repository has two tsconfig files.

I've defined exports field as follows, but this package was treated as ESM because "type": "module" is defined. As a result, This package can not be requred from CJS without dynamic import. (Node.js treats *.js file as ESM by "type": "module")

  "exports": {
    ".": {
      "require": "./lib/check-ends-with-period.js",
      "import": "./module/check-ends-with-period.js"
    }
  },

I could not found just works solution without using bundler/post scripts.

If targetExtension option exists, I can resolve this issue by tsconfig file.

  • tsconfig.json + "targetExtension": ".mjs",: generates esm to module/*.mjs from src/*.ts
  • tsconfig.cjs.json + "targetExtension": ".cjs",: generates cjs to lib/*.cjs from src/*.ts

However, this option will need to rewrite import path of source code. It oppsite TypeScript's design goal.

@azu @milesj It looks like that tsc-multi might be worth exploring cc @tommy351 #18442 (comment)

@azu
Copy link

azu commented Jan 14, 2023

I've created tsconfig-to-dual-package for avoiding my issue that is described in #49462 (comment)
This tool add package.json which is { "type": "module" } or { "type": "commonjs" } based on tsconfig's module and outDir options.
It resolve my issue by adding each type pacakge.json to lib/(CJS) and module/(ESM) instead of use .cjs and .mjs
This behavior is described in following:

In other words, Both(CJS and ESM) are *.js.
The result is much closer to what I wanted to do.

$ tsc -p . && tsc -p ./tsconfig.cjs.json && tsconfig-to-dual-package
#                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                                          \ I want to remove it!

This approch pros is that no require addtional build/transpiler tool(no modify output code of tsc).
Cons is that need to copy package.json to outDir.
(Of course, dual package hazard is in here, but this hazard is also in browser's iframe/realm or multiple versions of the same library. Not ideal, I believe the actual harm is limited.)

📝 Note

TypeScript may change the moduleResolution, but did not likely change the output.

I feel like there is confusion everywhere about ESM support. Therefore, I thought that this is not an issue of TypeScript configuration, but rather an ecosystem-wide issue that needs to move forward.

@gfortaine
Copy link

I've created tsconfig-to-dual-package for avoiding my issue that is described in #49462 (comment) This tool add package.json which is { "type": "module" } or { "type": "commonjs" } based on tsconfig's module and outDir options. It resolve my issue by adding each type pacakge.json to lib/(CJS) and module/(ESM) instead of use .cjs and .mjs This behavior is described in following:

In other words, Both(CJS and ESM) are *.js. The result is much closer to what I wanted to do.

$ tsc -p . && tsc -p ./tsconfig.cjs.json && tsconfig-to-dual-package
#                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                                          \ I want to remove it!

This approch pros is that no require addtional build/transpiler tool(no modify output code of tsc). Cons is that need to copy package.json to outDir. (Of course, dual package hazard is in here, but this hazard is also in browser's iframe/realm or multiple versions of the same library. Not ideal, I believe the actual harm is limited.)

📝 Note

TypeScript may change the moduleResolution, but did not likely change the output.

I feel like there is confusion everywhere about ESM support. Therefore, I thought that this is not an issue of TypeScript configuration, but rather an ecosystem-wide issue that needs to move forward.

@mobsense @jwalton @owenallenaz

@fabis94
Copy link

fabis94 commented May 10, 2023

I don't get the "you shouldn't be dual building" opinion, clearly there's a lot of fragmentation in the ecosystem currently and while everyone's moving in the direction of using ESM, there's still a lot of CJS projects and that's not going to change overnight. Because of this reason if you're building a library, it's important to output both in CJS and MJS.

Additionally, "type": "module" and "moduleResolution": "nodenext"/"node16" are the future, and in a project with these set any CJS build results outputted with a .js extension simply won't work.

Thus, it makes perfect sense to me that instead of outputting results with a vague ".js" extension which causes issues and headaches, you'd want to have explicit .cjs and .mjs extensions for CommonJS and ESModule modules respectively. On top of that, outputted declaration files should have d.cjs and .d.mjs extensions respectively, cause another issue I've ran into multiple times recently is library authors outputting results in .mjs and .cjs, but leaving the declaration files with the .d.ts extension which TypeScript then won't be able to pick up on.

@leeren
Copy link

leeren commented Jul 12, 2023

What are people currently using to work around this? Separate build steps with manual file extension rewriting?

@azu
Copy link

azu commented Jul 12, 2023

I believe that Dual Package can be achieved broadly as follows.

  1. Write two source codes CJS and ESM respectively
    • Write two source codes by hand
    • Handwritten, which is expensive to maintain.
  2. Generate the CJS code from one source code and make the ESM a wrapper that only imports CJS.
  3. Generate the ESM code from one source code and make the CJS a wrapper that only imports ESM.
    • The majority of the source code is in ESM format.
    • The reverse pattern of 2.
    • The CJS entry point imports the ESM via Dynamic Import.
    • Limitations: synchronous APIs cannot be provided from the CJS format.
    • Dynamic Import Proxy pattern.
    • e.g. Prettier v3, Vite
  4. Generate CJS and ESM code from one source code

@morganney
Copy link

morganney commented Jul 14, 2023

I stumbled upon this issue recently while wanting to build a dual package using babel and typescript. This file extension proliferation in the JS ecosystem is, needless to say, frustrating.

I just started a project babel-dual-package that takes "type": "module" packages and creates separate ESM and CJS builds with file and import/export extensions correctly updated. This includes declaration .d.ts files as well. All you need to do is add your exports field in package.json to match the build output. If you use babel and typescript together it might be helpful.

Running babel-dual-package --out-dir dist --extensions .ts,.mts,.cts src will get you an ESM build in dist and a CJS build in dist/cjs. Then define your exports accordingly.

@owenallenaz
Copy link

What we ended up doing is simply... not supporting ESM. In my eyes, the ESM ecosystem is simply not robust enough and we were spending more time trying to fight through it than actually solving real problems. Maybe once the tooling is there, but it's simply not. I want to be able to import using barrels like import Foo from "./Foo" where that references a file that is /foo/index.ts or /foo/index.tsx. It works in typescript natively, but when we compile for ESM it's busted. If developers want us to use ESM it needs to be ESM and no mandate that your entire toolset be built in ESM. If TS only worked with TS, it never would have got off the ground.

@WoodyWoodsta
Copy link

WoodyWoodsta commented Jul 21, 2023

To add my two cents, regardless of whether you want to emit dual variants, module: "commonjs" should output files with either a cjs extension, or a js extension.

At the moment, if you write your source code as mts, the compiler emits commonjs code but in mjs files which to me seems incorrect and broken. I don't think there should be a question of "why would you want to do that" - the compiler offers a module option, and so the options should output spec-compliant code, or should produce an error if the inputs don't comply with the config or output format.

@knightedcodemonkey
Copy link

knightedcodemonkey commented Jul 26, 2023

If you want to update your ESM/CJS specifiers pre/post build, check out @knighted/specifier. It will parse a file and update specifiers using a provided callback or regex map. Then write the updated source to disk using whatever file extension you want.

You can also try @knighted/duel (which uses @knighted/specifier) to easily create a dual CJS build. Here's an example repo, which is using the default args of -p tsconfig.json -x .cjs, so the build command as an npm run script amounts to duel.

knightedcodemonkey added a commit to knightedcodemonkey/duel that referenced this issue Jul 28, 2023
@knightedcodemonkey
Copy link

The fact that .mts files are always converted to the CJS module system anytime --module commonjs is used (despite the --moduleResolution used, or the type defined in package.json) needs to be addressed first. This breaks things, and makes building dual packages with tsc exclusively nearly impossible (ok, probably strictly impossible). This is clearly antithetical to how Node determines module systems.

See #54573 and all the related issues mentioned in this comment.

The file extension nonsense is annoying, but that's how we let the two module systems that Node currently supports coexist.

@ceztko
Copy link

ceztko commented May 9, 2024

Today I also had the need for compiling .ts files to javascript files with a different extension. Instead of the proposed "targetExtension": ".cjs", or "targetExtension": ".mjs" I suggest something like "ensureQualifiedExtension" : "true" that behaves like this:

  • If we have "module": "commonjs" then the extension is always .cjs;
  • If we have "module": "es6", or es2015, es2020, es2022, esnext then the extension is always .mjs;
  • If we have "module": "node16" or nodenext then the extension respects the rules for module detection as documented and the final extension can be either .cjs or .mjs.

@ruojianll
Copy link

ruojianll commented Aug 14, 2024

I need this nowadays, but we may not need it in the future, so keep it silence. Let time forget it.

@Tofandel
Copy link

Tofandel commented Nov 12, 2024

If we have "module": "commonjs" then the extension is always .cjs;
If we have "module": "es6", or es2015, es2020, es2022, esnext then the extension is always .mjs;

I would tweak this a bit so that you get .cjs in the first case if your package.json contains "type": "module" and .js otherwise
And vice versa for the second case, you get .mjs if package.json doesn't contain "type": "module" and .js otherwise

@ceztko
Copy link

ceztko commented Nov 12, 2024

I would tweak this a bit so that you get .cjs in the first case if your package.json contains "type": "module" and .js otherwise
And vice versa for the second case, you get .mjs if package.json doesn't contain "type": "module" and .js otherwise

I think "type": "module" is evaluated only with "module": "node16" or nodenext automatic module detection. In my suggestion, the extension is driven first by the module setting in tsconfig.json, and automatically enforces a qualified extension which in some cases is really desired/required.

In my understanding your tweak, as you call it, would introduce a slack rule unconditionally evaluating "type": "module" unregarding of module setting in tsconfig.json, and would not supply a stable extension for people that need an early enforcement of the module convention.

Anyway, other than few thumbs up, I don't see any reaction that suggests the general experience will be improved any time soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests