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

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("./")
)

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 1
  • Comments: 19 (7 by maintainers)

Commits related to this issue

Most upvoted comments

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

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.

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.

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

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.

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.