Skip to content

.mts file extension does not work as expected (tsc emits commonJS code instead) #51990

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

Closed
marco-a opened this issue Dec 21, 2022 · 19 comments
Closed
Labels
Duplicate An existing issue was already created

Comments

@marco-a
Copy link

marco-a commented Dec 21, 2022

Bug Report

I have created a post on stackoverflow asking about this behaviour, I haven't gotten a single reaction (yet). I asked on stackoverflow first, because I thought I might used tsc wrong.

However, getting no reaction in conjunction with the "obvious" expected behaviour I decided to file a bug report here.

The behaviour is easily reproducable by first installing typescript like so: npm install --save-dev typescript then creating the example.mts file and then running ./node_modules/.bin/tsc example.mts - that's all.

I expect *.mts files to be convert into VALID *.mjs files WITHOUT using a configuration, I think that's a reasonable expectation.

🔎 Search Terms

  • .mts

🕗 Version & Regression Information

  • This is the behavior in version 4.9.4.

💻 Code

// file name: example.mts
import path from "path"

console.log(
    path.resolve("./")
)

🙁 Actual behavior

// file name: example.mjs
"use strict";
exports.__esModule = true;
var path_1 = require("path");
console.log(path_1["default"].resolve("./"));

This is not a valid .mjs file because ES Modules do not have a require function.

🙂 Expected behavior

// file name: example.mjs
import path from "path"

console.log(
    path.resolve("./")
)
@andrewbranch
Copy link
Member

Essentially a duplicate of #50647. In that issue, the CJS/ESM is flipped from yours, but the point is that these extensions are only meaningful in one configuration mode, which is not the default. I agree with your expectation FWIW, but it gets a lot more complicated if you follow the principle all the way through. There is some related discussion in #49270 as well.

@marco-a
Copy link
Author

marco-a commented Dec 22, 2022

I'm sorry for the duplicate issue, I wasn't exactly sure how to find an existing issue with the terms I had in mind (I only thought of .mts).

Uhm, I can see why this might be difficult to make tsc behave the way I anticipated.

The reason I want to do it that way is to avoid configuring anything.

I'm not a fan of configurations and try to make it work by using as little config as possible.

Speaking of, I think you understand what I wanna achieve: I want to use ESModules with Types: essentially I want .mjs files with the addition of having the capability of types.

I tried to configure tsc to achieve just that, and I had some success:

{
  "compilerOptions": {
    "target": "es2016",                                /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "es2015",                                /* Specify what module code is generated. */
    "moduleResolution": "node16",                      /* Specify how TypeScript looks up a file from a given module specifier. */
    "outDir": "./dist/",                               /* Specify an output folder for all emitted files. */
    "esModuleInterop": false,                          /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": false,         /* Ensure that casing is correct in imports. */
    "strict": true,                                    /* Enable all strict type-checking options. */
    "skipLibCheck": true                               /* Skip type checking all .d.ts files. */
  },
  "include": ["./src/*.ts"],
}

The problem here is that allowSyntheticDefaultImports needs to be set to true in order for

import path from "path"

To work. Otherwise tsc will complain with:

error TS1259: Module '"path"' can only be default-imported using the 'allowSyntheticDefaultImports' flag

But I don't think setting this flag to true is correct in my use case. I don't plan on using any commonJS modules, so I certainly don't need the interop.

Node running the file on its own does not complain and it works just fine. Leading me to assume that import path from "path" correctly exports the default object.

Can you tell me what configs I need to set to get the behaviour I'm looking for?

@andrewbranch
Copy link
Member

module should be node16, and you should leave esModuleInterop on. It will only affect your emit if you write CommonJS files, so there’s no harm in leaving it on.

@marco-a
Copy link
Author

marco-a commented Dec 23, 2022

Without checking I'll take your word for it.

Thank you for your help!

@fatcerberus
Copy link

fatcerberus commented Dec 23, 2022

The reason I want to do it that way is to avoid configuring anything.

Yeah, this isn't really practical for TypeScript. There are just too many variables w.r.t. where the code is going to be run, what ES version is supported by the target environment(s), etc. Sane defaults are great, but there's only so far that goes before you can no longer make everyone happy and configuration is required.

That said, TS's defaults really aren't sane these days - there's a lot of legacy baggage there (default target of es3, anyone?). It'd be great to at least have node16 as the default module mode, now that ES modules are mainstream.

@typescript-bot
Copy link
Collaborator

This issue has been marked as a 'Duplicate' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@knightedcodemonkey
Copy link

knightedcodemonkey commented Jul 29, 2023

The TypeScript compiler is fundamentally broken in its handling of --module combined with its support for the new file extension .mts.

Let's say you want to create a CJS library, but also have some tooling scripts that are written in ESM, for whatever reason (and there are reasons), that should likewise be part of the build output. For the most part your project uses .ts extensions for the source files, but the ESM scripts are using .mts to indicate they are ES modules, regardless of the type in package.json, because that is after all how Node determines module systems. Well, unfortunately you are out of luck if you want to do this with tsc because you'd have to pass --module commonjs which always converts the file with the .mts extension, to one with an .mjs extension, but using the CJS module system!

Moreover, if you were wrapping tsc to create a tool for generating dual packages from an ESM-first project that uses an .mts extension (for whatever reasons) while also using --module nodenext, and wanted to fix the issue described above by creating a separate, dynamic tsconfig.json for the CJS build that changes to --module commonjs while removing any .m[tj]s files so they can be copied from the ESM build, you're again blocked by the way the exclude option works. Yes, you can just overwrite the broken .mjs files generated by the latter build, but that's beside the point.

This article on ECMAScript Modules in Node.js should be updated to warn about what's described here. Particularly this part under New File Extensions:

In turn, TypeScript supports two new source file extensions: .mts and .cts. When TypeScript emits these to JavaScript files, it will emit them to .mjs and .cjs respectively.

I would suggest this diff:

- In turn, TypeScript supports two new source file extensions: .mts and .cts.
+ In turn, TypeScript partially supports two new source file extensions: .mts and .cts.
- When TypeScript emits these to JavaScript files, it will emit them to .mjs and .cjs respectively.
+ When TypeScript emits these to JavaScript files, it will emit them to .mjs and .cjs respectively,
+ but the .mjs will incorrectly use the CommonJS module system if you pass --module commonjs to the compiler.

It seems the only thing for tsc to do when it encounters the new file extensions, is to leave the module system alone, no matter what --module value is passed. Or add more options, whatever you prefer, but this needs to be corrected if tsc wants to produce output that works correctly with Node's resolution of module systems. For what it's worth, it seems you can preserve the .cts extension's module system.

@andrewbranch
Copy link
Member

if tsc wants to produce output that works correctly with Node's resolution of module systems

It absolutely does not, in --module commonjs. That’s explicitly not a goal of any module option except node16 and nodenext. If you intend to run in Node, not specifying one of those two module modes is a configuration error.

@knightedcodemonkey
Copy link

If you intend to run in Node, not specifying one of those two module modes is a configuration error.

Yes, by the decisions of the TypeScript team. There is no way to preserve the module system of .mts files while also compiling other .ts files in the project to CJS.

This seems like a clear violation of your stated design goals 4 and 7.

Can you tell me how to compile other .ts files in a project to CJS while preserving the .mts file's module system using tsc?

@andrewbranch
Copy link
Member

tsc --module nodenext

.ts files will be interpreted as CJS by default, just like Node interprets .js files as CJS by default. Adding "type": "module" to an ancestor package.json will make Node and tsc interpret .js/.ts files as modules instead.

@knightedcodemonkey
Copy link

knightedcodemonkey commented Jul 31, 2023

Ok, so tsc does consider the package.json type field. I was under the impression it only worked off of the compiler options.

So the only way to convert a given .ts file into both module systems across two tsc builds is to make another package.json file that uses the correct type while passing --module nodenext? Hmm, but then you bump into compiler errors like TS1479 unless you are willing to update the .ts file source code.

@andrewbranch
Copy link
Member

That’s right, dual module format emit is not a well-supported scenario. #54593

@knightedcodemonkey
Copy link

I'm currently working a tool to allow dual builds, and for the most part I think I've got it working but it requires copying files over from one build into the other to account for unexpected module systems emitted during the tsc build. If you're already looking at the package.json type field, you might want to consider looking at the exports field as well so that if a configured outDir matches a subpath from exports you know what module system is expected.

I'll look at that more closely. Thanks for your time, I appreciate the feedback.

@andrewbranch
Copy link
Member

We already do some mapping logic with respect to outDir in order to make module resolution of self-name imports work correctly; what we don’t do is read package.json files in the outDir to update our assumption about what the ultimate output format is going to be at runtime. That’s exactly what #54546 did, but there were pretty valid concerns laid out in the PR comments and summarized in #54593. Something like that is still on the table, but I’m not prioritizing it right now because

  • it’s not clear what the right DX is
  • we think dual ESM/CJS packages are often a bad idea
  • most people are going to use an external tool like tsup, dnt, pkgroll, etc. to do this instead of tsc, and the kinds of transforms they do (e.g. bundling devDependencies while externalizing dependencies) cannot easily be modeled in our module resolution / checking

@knightedcodemonkey
Copy link

You still are making arbitrary decisions, just like Node.js, but in contradiction to the latter. M$.

@craigmiller160
Copy link

My two cents:

Dual ESM/CJS packages are essential at the moment due to the convoluted degree of support for ESM in the NodeJS world. ie, bundlers need all ESM code for maximum tree shaking, whereas a lot of tooling still requires all CJS to be parse-able. Simply saying "that's a bad idea" isn't an acceptable solution. None of this is a good idea, it's all just the unfortunate reality of where the ecosystem is right now.

@luchillo17
Copy link

Uh anyone knows what in tarnation is happening here? I'm trying to make an express app with ESM output and TS, specifically in Node 20.11.0, and for some reason, it's complaining about a constant with an arrow function?
image
Is it my TSConfig?
image

@andrewbranch
Copy link
Member

Not related to this issue at all, but Webpack is complaining about TypeScript syntax. A loader to remove type annotations is not being used.

@luchillo17
Copy link

luchillo17 commented Feb 20, 2024

@andrewbranch Thanks, I've tried a lot of stuff based on this and a few other issues, and ended up changing tsc for swc, guess swc needs an extra loader and rule to load .mts files properly right? do you know which one is the common one?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

7 participants