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 · 35 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.

@jedwards1211
Copy link

jedwards1211 commented Dec 11, 2024

@RyanCavanaugh because many people use the default "moduleResolution" setting, which ignores export maps, the only way we can support subpath imports like import * from 'foo/bar' in a dual package without any TS users running into problems is to have bar.js and bar.mjs files in the root of the package. I'm pretty sure this is unavoidable for many major packages.

I'm also pretty sure @azu's tsconfig-to-dual-package won't work for people with default "moduleResolution" because it builds the CJS and ESM into subdirectories: azu/tsconfig-to-dual-package#15

I haven't seen any TS dual-package authoring tools that seem unproblematic. The popular tsc-alias uses regexes to rewrite import statements, and I'm guessing breaks source maps too.

If tsc would just add quality ways to build dual packages, it would be a lot easier for everyone to avoid the subtle pitfalls of popular tools and approaches.

@RyanCavanaugh
Copy link
Member

I haven't seen any TS dual-package authoring tools that seem unproblematic

The whole reason we don't want to do dual-emit is that there doesn't seem to be an unproblematic way to do it. What can TS do that they can't?

@morganney
Copy link

If you set moduleResolution to nodenext it’s possible to generate dual builds with tsc using only one tsconfig.json and package.json file. Anybody authoring a library today that wants to support dual build should be using nodenext anyways.

@jedwards1211
Copy link

jedwards1211 commented Dec 12, 2024

@RyanCavanaugh what makes general-purpose tools for dual-emit problematic is they have to either copy the source code to a temporary directory and codemod the import path extensions before compiling, running the risk of something breaking in the temporary location, or they have to destructively modify the source code in place before compiling.

Only tsc or babel (or maybe a 3rd-party tool that wraps the tsc internal API?) can codemod import paths in memory, transpile, and then output to a file with the desired extension.

I use a custom babel plugin for it, but of course most TS devs will not prefer this.
There's tsc-multi, which does seem to wrap TS internal API, but if you look at the source code it's very involved compared to hypothetically doing this in tsc.

And it's hard to make a majority of the community aware of 3rd-party tools for this the way tsc could make everyone aware of a new option.

Modifying the import path extensions is really simple compared to most of the transpilation that tsc does, changing the output file extensions is even simpler, and these options would be useful for a variety of dual package layouts.

And if tsc provided the option, most package authors would discover it and be able to get their dual package builds right from the get go instead of many getting it wrong.

@morganney are you talking about having both .(c)ts and .(m)ts in your source code? I've dealt with this problem for years and the only dual package layout I've found that supports the widest variety of situations (1. subpath imports from the package 2. package users who aren't using nodenext 3. not simply re-exporting the CJS modules from ES wrappers) is to transpile .ts source to both CJS and ESM files side by side, provide an export map for people whose tools can consume it, and fall back to relative path resolution to either the CJS or ESM files for users whose tools ignore the export map.

@morganney
Copy link

morganney commented Dec 12, 2024

@jedwards1211

No, I’m talking about a build tool that runs tsc twice while updating the type in package.json and rewriting specifiers. Use whatever file extension you want.

Which is more or less what you’re saying. The biggest hurdle to this being baked into tsc is that they refuse to rewrite specifiers.

Hopefully with the ability to require(esm) the need for dual building will grow less and less important.

@jedwards1211
Copy link

jedwards1211 commented Dec 12, 2024

Right, there are edge cases (e.g. subpath imports) you can only support by rewriting specifiers, and doing that inside tsc would be way more flexible and convenient than doing it with external tools.

Hopefully with the ability to require(esm) the need for dual building will grow less and less important.

If I did my research, this still requires an command line flag in Node 22, which doesn't reach end of life until April 2027, so if we want to support all users of LTS Node, we still have to dual build for another 2 1/2 years.

The choice between being conservative or helping the JS ecosystem run more smoothly for those 2 1/2 years is in the TS developers' hands.

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