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

Add flag to not transpile dynamic import() when module is CommonJS #43329

Closed
5 tasks done
dummdidumm opened this issue Mar 21, 2021 · 57 comments
Closed
5 tasks done

Add flag to not transpile dynamic import() when module is CommonJS #43329

dummdidumm opened this issue Mar 21, 2021 · 57 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@dummdidumm
Copy link

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.

dynamic import, commonjs, esm, node

βœ… 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 a tsconfig flag to not transpile dynamic imports when module is CommonJS. Something like transpileDynamicImport which would be true by default and only take effect when "module": "CommonJS".

πŸ“ƒ Motivating Example

Since Node 12, it is possible to use dynamic imports. Yet I'm not able to tell the TS compiler to not transpile this into Promise.resolve().then(() => __importStar(require('..')));. This prevents users from importing ES modules into CommonJS, which will become increasingly common now that Node is transitioning to ES modules.

πŸ’» Use Cases

The main use case is to be able to import ES modules into CommonJS, which is possible to do with import(). The workaround today involves an additional build step which does replacements to hide the import() statement from TS so it does not get transpiled and re-add it later on, which is suboptimal.

@marvinhagemeister
Copy link

Just ran into this myself and noticed that this breaks ESM interop in node. Per node's documentation it is possible to load esm files from commonjs via import(). Therefore the dynamic import statement must not be transpiled to require calls. This is currently broken when using TypeScript.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Mar 22, 2021
@RyanCavanaugh
Copy link
Member

@weswigham any additional context you might provide here?

@orta
Copy link
Contributor

orta commented Mar 25, 2021

I can as I've been asked about this in a few contexts. Today we assume that all requires and imports have the same end result post-transpilation, as either commonjs or esmodules.

However, it's a bit more nuanced because node supports the same keywords working differently depending on if you're in an ESM context or CommonJS context. So in a commonjs context, you normally can't use import/export, but you can use await import to import an esm module:

// index.cjs
let module = await import('/modules/my-module.js');

What TypeScript thinks today is that this import should be switched to a require

const a = Promise.resolve().then(() => __importStar(require("/modules/my-module.js")));

Which is what you want for existing cases because that was a normal feature of require statements. It's a signal that the thing you're about to grab is a ESM module.

What's tricky is that TypeScript has no way to disambiguate whether you want await import in a cjs context to continue being a = require( or now stay as a = await import(. We'd either need a flag like the above which handles it app wide, or a pragma at the import call-site.

This seems to be hitting a few big projects because people want to have config files in ESM but let the app stay in cjs.

@typhonrt
Copy link

typhonrt commented Apr 1, 2021

Greets... Thanks @dummdidumm for filing this issue. I was about to post the same thing about a week ago.

@orta not transpiling dynamic import is an important aspect to enable for Node v12+ in regard to loading ESM for CommonJS targeted TS efforts. It will be used for a lot more than loading configuration files. I am an outside contributor attempting (ESM support was added see - oclif/core#130) to get Heroku / Salesforce to add ESM loading capabilities to their Oclif v2 CLI framework that is due for launch in the coming months. I have already worked out the essential changes with a proof of concept that is discussed in the issue linked above, but it required a workaround. Oclif is a TS project which targets CommonJS for release. One can build CLIs in CommonJS or TS; my proposed changes adds ESM to the mix which is quite relevant as the Node ecosystem moves to ESM.

The least worst workaround I could come up with is the following:
const _importDynamic = new Function('modulePath', 'return import(modulePath)') which is used internally to an encapsulated addition for loading ESM via dynamic import or require for non-ESM code - module-loader.ts.

As an outside contributor I certainly could not touch the build process, so the above seems to do the trick until a flag can be added to Typescript which is certainly desirable.

@aomarks
Copy link

aomarks commented May 1, 2021

What's tricky is that TypeScript has no way to disambiguate whether you want await import in a cjs context to continue being a = require( or now stay as a = await import(. We'd either need a flag like the above which handles it app wide, or a pragma at the import call-site.

If node style module resolution is enabled, and you encounter a bare module specifier in a dynamic import, would it make sense to check the imported package's package.json for "type": "module" and preserve the dynamic import if it is present?

@yinzara
Copy link

yinzara commented May 23, 2021

If node style module resolution is enabled, and you encounter a bare module specifier in a dynamic import, would it make sense to check the imported package's package.json for "type": "module" and preserve the dynamic import if it is present?

If you also checked the extension of the file being imported for ".mjs", then yes I think that would be appropriate.

This has become a big problem for using some packages in a CommonJS app that are MJS only.
sindresorhus/meta#15

LinqLover added a commit to LinqLover/downstream-repository-mining that referenced this issue Jun 8, 2021
Reuse dynamicImport workaround by @TomerAberbach due to microsoft/TypeScript#43329 for escape-string-regexp as well
@styfle
Copy link
Contributor

styfle commented Jun 18, 2021

The beauty of await import() is that it can import both ESM and CJS:

// index.cjs

const one = await import('./new.mjs');
const two = await import('./old.cjs');
const three = await import('./unknown.js');

This is highly desirable to maintain interop as the ecosystem slowly moves towards ESM because I can import a file without knowing the module system. The current all-or-nothing approach is too painful.

We'd either need a flag like the above which handles it app wide, or a pragma at the import call-site.

I would be okay with either solution, but the pragma would give the most flexibility if it can be configured per call site.

@orta
Copy link
Contributor

orta commented Jun 19, 2021

This is going to be coming in natively and without a flag/pragma I expect, when more of the node ESM support starts rolling after #44501

@voxpelli
Copy link

voxpelli commented Aug 7, 2021

For future reference: A workaround for this issue can be had by moving the import() to a non-compiled dependency, such as inclusion.

Biggest drawback of this, apart from it being confusingly non-standard, is that types won't get imported. That can be worked around by manually importing the types.

Complete example:

import inclusion from 'inclusion';

export async function foo(): Promise<void> {
  const pMap: typeof import('p-map')['default'] = (await inclusion('p-map')).default;
}

aomarks added a commit to lit/lit.dev that referenced this issue Sep 1, 2021
The lit-dev-tools package is currently CommonJS, because mostly it
is used for Eleventy plugins, and Eleventy doesn't support ES
modules (11ty/eleventy#836).

We want ES modules for this new redirect checker script, because it
needs to import some ES modules, and that is difficult to do with
TypeScript, because TypeScript doesn't allow emitting an actual
`import` statement, which is how CommonJS -> ESM interop works
(microsoft/TypeScript#43329).

We also an't really have a mix of CommonJS and ESM in the same package,
because the {"type": "module"} field has to be set. We could use
.mjs extensions, but TypeScript won't emit those.

So the simplest solution seems to be to just have two packages!
aomarks added a commit to lit/lit.dev that referenced this issue Sep 1, 2021
Custom script for checking lit.dev redirects.

It would be nice if we could use the 3rd party link checker we already have for this somehow, but it doesn't support checking for anchors (see stevenvachon/broken-link-checker#108 -- understandable since it would require DOM parsing) which is one of the main failure cases.

Fixes #467 (since we shouldn't need comments if we have the redirects checked in CI).

As part of this, I created a new lit-dev-tools-esm package.

The existing lit-dev-tools package is currently CommonJS, because mostly it is used for Eleventy plugins, and Eleventy doesn't support ES modules (11ty/eleventy#836).

We want ES modules for this new redirect checker script, because it needs to import some ES modules, and that is difficult to do with TypeScript, because TypeScript doesn't allow emitting an actual import statement, which is how CommonJS -> ESM interop works (microsoft/TypeScript#43329).

We also can't really have a mix of CommonJS and ESM in the same package, because the {"type": "module"} field has to be set to one or the other in the package.json. We could use .mjs extensions, but TypeScript won't emit those.

So the simplest solution seems to be to just have two packages.
@NoyHanan
Copy link

NoyHanan commented Aug 8, 2023

This is brilliant. But I like this:

Function('return import("node-fetch")')() as Promise<typeof import('node-fetch')>

Apparently it's much(?) faster, not sure but maybe even safer.

Hello everyone, has anyone else encountered a problem while using this workaround with Jest?

joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 5, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 5, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 5, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 5, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 5, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 6, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
joachimvh added a commit to CommunitySolidServer/CommunitySolidServer that referenced this issue Oct 6, 2023
The new version is an ESM package,
so we need to do a dynamic import as our package is CJS.
To correctly transpile the dynamic import,
moduleResolution needs to be set to node16.
See microsoft/TypeScript#43329
@fcano-ut
Copy link

Unfortunately, on our use case none of the proposed workarounds work, because they are incompatible with a strict Content Security Policy. We cannot add unsafe-eval to the CSP policies due to client requirements, so doing await eval or new Function('') is not possible for us.

If you are asking why would I transpile to CJS even though I'm using the module in the browser: a) I'm already using webpack so I don't care about module formats, and b) Jest doesn't support ESM and I don't want my users to be messing up with Jest and babel transforms just to run some tests.

Is there any workaround that doesn't require to eval code, or is there a flag planned to solve this issue? Thanks

@kth-tw
Copy link

kth-tw commented Oct 31, 2023

It is work for me after set tsconfig.json compilerOptions to node16

 "compilerOptions": {
   "moduleResolution": "node16",
 }

@Zielak
Copy link

Zielak commented Nov 15, 2023

It is work for me after set tsconfig.json compilerOptions to node16

 "compilerOptions": {
   "moduleResolution": "node16",
 }

That doesn't work with module CommonJS:

Option 'module' must be set to 'Node16' when option 'moduleResolution' is set to 'Node16'.

mmkal added a commit to sequelize/umzug that referenced this issue Dec 8, 2023
Closes #608

This adds some esm-vs-commonjs "smartness" to umzug:

1. use `require` for `.cjs` migrations and `import` for `.mjs`
migrations (and their typescript equivalents)
2. use `require` for `.js` migrations _if_ `typeof require.main ===
'object'`, and `import` to `.js` migrations otherwise
3. use the same criteria to create (c)js vs mjs templates when creating
migration files
4. add `"moduleResolution": "Node16"` to tsconfig.lib.json to make sure
`import(filepath)` doesn't get transpiled into
`__importStar(require(filepath))` (see
[here](microsoft/TypeScript#43329 (comment))
and
[here](microsoft/TypeScript#43329 (comment)))

Tests/examples:

- add a `vanilla-esm` example to make sure using `import` / top-level
await works
- add a step to the `test_pkg` job to make sure vitest isn't hiding
gnarly import problems - this is installing the compiled library as a
`.tgz`, and with no other dev/prod dependencies like vitest or ts-node
having been installed, so should be very close to what end users will do

Didn't:

- add a wrapper.mjs file in the compiled folder as suggested in
#608 (comment),
mostly just because it didn't seem to be necessary? It seems to work
fine when imported from an ES-module, using top-level await, etc., even
though umzug is itself a commonjs module.

<details>
<summary>original body</summary>

~Related to #608 - although does not close it.~

~This adds built-in support for `.mjs` and `.mts` files. `.mjs` should
"just work" - write migrations as ESM modules and they'll be imported in
the right way. For the current major version, `.js` will continue to be
assumed commonjs. ESM-fans will just have to type that extra `m` in
their filenames.~

~This PR _doesn't_ add a wrapper file so that the umzug library itself
can be imported as an ES module. That can be done in a follow-up PR. In
the meantime, ESM users can use `createRequire` as in the [existing ESM
example](https://github.com/sequelize/umzug/tree/main/examples/2.es-modules).~
</details>

---------

Co-authored-by: Misha Kaletsky <mmkal@users.noreply.github.com>
@elovin
Copy link

elovin commented Dec 21, 2023

I also had to enable "skipLibCheck": true:

		"lib": ["ES2022"],
		"target": "ES2022",
		"module": "Node16",
		"moduleResolution": "Node16",
		// currently necessary due to ESM only imports
		"skipLibCheck": true,

@shirakaba
Copy link

shirakaba commented Feb 18, 2024

Solution to a challenging case

I solved a particularly difficult case this way, by writing a module in CommonJS outside of the TypeScript source tree (so that it doesn't get compiled) that simply re-exports the desired ESM library. Here's the setup:

  • The project is an npm package named @expo/cli.
  • It transpiles the TypeScript files in src to CommonJS files in build.
  • We want to import the ESM package globby, but TypeScript is unfortunately transpiling all our dynamic imports into require() statements.
  • Not actually relevant to this issue, but does complicate the solution: We can't use relative imports that climb outside of the src folder, because the build system outputs a directory structure that's not symmetrical with the source tree (it's nested one level deeper).

Here's the project tree visually (I added the lib directory to solve the issue at hand):

  .
  β”œβ”€β”€ build
  β”‚   β”œβ”€β”€ bin
  β”‚   β”‚   └── cli.js
  β”‚   └── src
  β”‚      β”œβ”€β”€ index.js.map
  β”‚      └── index.js
+ β”œβ”€β”€ lib
+ β”‚   β”œβ”€β”€ importGlobby.d.ts
+ β”‚   └── importGlobby.js
  β”œβ”€β”€ package.json
  β”œβ”€β”€ src
  β”‚   └── index.ts
  └── tsconfig.json

File contents

lib/importGlobby.js

First, you make a CommonJS module that dynamically imports and then returns the globby ESM module:

/**
 * A re-export of globby.
 *
 * globby is an ESM module, and so can only be imported into a CommonJS project
 * by using dynamic import(). However, TypeScript transpiles import() to CommonJS.
 * @see https://stackoverflow.com/questions/65265420/how-to-prevent-typescript-from-transpiling-dynamic-imports-into-require
 * @see https://github.com/microsoft/TypeScript/issues/43329
 */
async function importGlobby() {
  return await import('globby');
}
exports.importGlobby = importGlobby;

lib/importGlobby.d.ts

Optionally, you can write some typings alongside it.

export declare async function importGlobby(): Promise<typeof import('globby')>;

src/index.ts

If this issue didn't exist (i.e. if globby were a CommonJS module), we'd be writing something as simple as this:

// If this issue didn't exist
import { globbyStream } from 'globby';

export async function demo(){
  for await (const path of globbyStream('*.tmp')) {
    console.log(path);
  }
}

... but as that's not the case, see our workaround code below.

If you have a symmetrical build tree

For simple projects with a symmetrical build tree, you can consume the module as easily as this:

const { importGlobby } = require('../../lib/importGlobby');

export async function demo(){
  // Hurrah, we've imported the `globbyStream` function from the `globby` module!
  const { globbyStream } = await importGlobby();

  // Now just use `globbyStream` as usual:
  for await (const path of globbyStream('*.tmp')) {
    console.log(path);
  }
}

If you have an asymmetrical build tree

However, if, like me, you have an asymmetrical build tree, then your best option to get both typings and implementation may be to import relative to the package itself, or just leave it with any type.

Otherwise, you could just use it untyped.

// Do a little dance to grab the typings
import type importGlobbyModule from '../../lib/importGlobby';

// Import the module relative to the project (our project is @expo/cli)
const { importGlobby } = require('@expo/cli/lib/importGlobby') as typeof importGlobbyModule;

export async function demo(){
  // Hurrah, we've imported the `globbyStream` function from the `globby` module!
  const { globbyStream } = await importGlobby();

  // Now just use `globbyStream` as usual:
  for await (const path of globbyStream('*.tmp')) {
    console.log(path);
  }
}

@juliolopeztorres
Copy link

This is brilliant. But I like this:

Function('return import("node-fetch")')() as Promise<typeof import('node-fetch')>

Apparently it's much(?) faster, not sure but maybe even safer.

Hello everyone, has anyone else encountered a problem while using this workaround with Jest?

I did but not exactly your case. I faced an error using the eval() workaround.

At the end, I decided to add --experimental-vm-modules option to NODE_OPTIONS before calling Jest. For reference, I am using Jest 29.5 at the moment with node 20.13.

My script line in package.json looks like:

...
    "jest": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest ..."
...

@JinYuSha0
Copy link

I'm dying of this problem.
I tried using tsconfig.json to exclude file but it didn't work
In the end, I chose to just make a simple npm package await-import-dont-compile to prevent compilation

This is how he uses it

import awaitImport from "await-import-dont-compile";
import { pathToFileURL } from "url";

export const dynamicImport = async (filepath: string) => {
  return await awaitImport(pathToFileURL(filepath).href);
};

Hope it helps!!!

@wug-ge
Copy link

wug-ge commented Nov 13, 2024

If someone else is facing this issue but doesn't want to install an additional package, this is an ugly but possible workaround:
const { Provider } = await new Function("return import('oidc-provider')")()

Found in this solution here:
panva/node-oidc-provider#1249

@aamersaleem9999
Copy link

aamersaleem9999 commented Nov 20, 2024

end of 2024 and it is still an issue. sigh...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests