microsoft / TypeScript

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

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

Open bluelovers opened 2 years ago

bluelovers commented 2 years ago

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:

⭐ Suggestion

add something like

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

📃 Motivating Example

💻 Use Cases

IllusionMH commented 2 years ago

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 commented 2 years ago

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

milesj commented 2 years ago

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

bluelovers commented 2 years ago

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 commented 2 years ago

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.

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

milesj commented 2 years ago

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 commented 2 years ago

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.

alvis commented 2 years ago

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 commented 1 year ago

@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 commented 1 year ago

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 commented 1 year ago

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 https://github.com/microsoft/TypeScript/issues/18442#issuecomment-1369110873

azu commented 1 year ago

I've created tsconfig-to-dual-package for avoiding my issue that is described in https://github.com/microsoft/TypeScript/issues/49462#issuecomment-1152860276 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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

azu commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 microsoft/TypeScript#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 commented 6 months ago

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:

ruojianll commented 2 months ago

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