microsoft/TypeScript

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

dummdidumm opened this issue ยท 57 comments

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.

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.

@weswigham any additional context you might provide here?

orta commented

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.

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.

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?

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

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 commented

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

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;
}

as I workaround I use

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

Fixed by the addition of module: node12 with #45884

Fixed by the addition of module: node12 with #45884

@weswigham While debugging someone's question on Stack Overflow, I observed that setting TSConfig compilerOptions.module to "node12" doesn't resolve the issue of preventing transformation of dynamic import() into require(), but that using eval does.

Version info:
$ node --version
v16.13.1

$ npm --version
8.3.0

$ npm ls typescript
so-70545129@1.0.0 /so-70545129
โ””โ”€โ”€ typescript@4.6.0-dev.20211231

package.json
{
  "name": "so-70545129",
  "version": "1.0.0",
  "description": "",
  "type": "commonjs",
  "main": "dist/index.js",
  "scripts": {
    "compile": "tsc",
    "test": "npm run compile && node dist/index.js"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "@types/node": "^17.0.5",
    "typescript": "^4.6.0-dev.20211231"
  },
  "dependencies": {
    "unified": "^10.1.1"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "exactOptionalPropertyTypes": true,
    "isolatedModules": true,
    "lib": [
      "ESNext"
    ],
    "module": "node12",
    "moduleResolution": "Node",
    "noUncheckedIndexedAccess": true,
    "outDir": "dist",
    "strict": true,
    "target": "ESNext",
  },
  "include": [
    "./src/**/*"
  ]
}

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.

We also cannot confirm that using node12 as module setting solves the problem. We're still getting dynamic imports transformed to require, as @jsejcksn mentions

We also cannot confirm that using node12 as module setting solves the problem. We're still getting dynamic imports transformed to require, as @jsejcksn mentions

This functionality has never made it into released TypeScript. It was reverted

#46452

This functionality has never made it into released TypeScript. It was reverted

#46452

should this issue be reopened then?

Since the function was reverted, the documentation needs t be adopted. Here is written that the moduleResolution node12 and nodenext are supported from typescript 4.5 onwards:

https://www.typescriptlang.org/tsconfig#moduleResolution

should this issue be reopened then?

YES please

It wasn't reverted, per sey, it just issues an error in stable builds to let people know the feature is still a bit unstable. (Also, we close issues once they're fixed in nightly)

Rush commented

I was hoping to fix a storybook build issue by using "nodenext". Unfortunately "import" is still being transpiled.

Rush commented

Also, we close issues once they're fixed in nightly)

How come a fix from nightly from September last year is still not available in stable? And in general shouldn't an issue be marked as fixed when it is reported as fixed? Developers should not assume an issue is closed just because code is merged.

Is this fixed already with nodenext?

Correct - cjs format files in node12 or nodenext retain dynamic imports as-is, even while other imports are transformed.

Workaround: https://github.com/brillout/load-module.

If someone can re-open this for folks that subscribe to the close status of this ticket, that would be great.

Correct - cjs format files in node12 or nodenext retain dynamic imports as-is, even while other imports are transformed.

I get this:

error TS4124: Compiler option 'moduleResolution' of value 'nodenext' is unstable.
Use nightly TypeScript to silence this error. Try updating with 'npm install -D typescript@next'.

I'm just seeing it does work with typescript@4.7.0-beta though.

jekh commented

Correct - cjs format files in node12 or nodenext retain dynamic imports as-is, even while other imports are transformed.

Is this still nightly-only, even in 4.7? With module set to node16, dynamic import is still getting transformed. For example

  await import("got")

becomes

  await Promise.resolve().then(() => __importStar(require("got")));

Here's a playground link

Update: It appears the playground "Share" link loses the Target and Module settings (resets to ES3 and None, respectively). If you set Module to Node16 and Target to ES2021 (or another modern target) in the "TS Config" menu, it will repro. I've also confirmed this with standard command-line tsc 4.7.4 (where I originally noticed it).

I can also confirm that I'm still having this issue on version 4.7.4 with the following compiler options:

    "outDir": "./lib",
    "rootDir": ".",
    "module": "esnext",
    "target": "ES2021",
    "types": ["node"],
    "importHelpers": false
  }```
IanVS commented

Many of the packages that have started being distributed as ESM only (even those intended to be used in node) use the rationale "You can use import() to load ESM packages into commonjs files." However, this is not possible in typescript (stable) when targeting commonjs as far as I can tell. Am I missing something, or is that the case? So, for example, all of the latest packages from sindresorhus are off-limits to typescript developers compiling to cjs until this issue is resolved and nodenext is released out of nightly, right?

Yes and he has been aware of this. There are a number of very long horrible threads now spanning years asking people like him to stop doing this until the community can catch up. But they ignored us.

Many of the packages that have started being distributed as ESM only (even those intended to be used in node) use the rationale "You can use import() to load ESM packages into commonjs files." However, this is not possible in typescript (stable) when targeting commonjs as far as I can tell. Am I missing something, or is that the case? So, for example, all of the latest packages from sindresorhus are off-limits to typescript developers compiling to cjs until this issue is resolved and nodenext is released out of nightly, right?

It is possible, hacky, but at least possible.

same issue closed with not correctly resolved:

I spent a good afternoon reading up on everything and I did not find a good example of how to do this so here is my solution.

Wrap your ESM package into a .cjs commonjs script that will not be transpiled and import like this.

import { fileTypeFromBuffer } from './file-type.cjs';

file-type.cjs

/**
 * @type {typeof import('file-type').fileTypeFromBuffer}
 */
module.exports.fileTypeFromBuffer = async (buffer) => {
  const { fileTypeFromBuffer } = await import('file-type');
  return await fileTypeFromBuffer(buffer);
};

tsconfig.json

"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2021",
"lib": ["ES2021"],

package.json

  "type": "commonjs",
   "dependencies": {
     "typescript": "4.8.2"
   },

.nvmrc

16.17.0

I worked around this with the magic of eval!

...and a js file.

getPhonemeLibs.js


/* eslint-disable no-eval */

// these libs are ESM only and typescript doesn't yet have great support for that when you
// want to compile to commonjs. the only way I've found to get typescript to not compile a dynamic
// import is to use eval.
export async function getPhonemeLibs() {
  let { soundex } = await eval("import('soundex-code')")
  let { doubleMetaphone } = await eval("import('double-metaphone')")
  return { soundex, doubleMetaphone }
}

and!
getPhonemeLibs.d.ts

import type { doubleMetaphone } from 'double-metaphone'
import type { soundex } from 'soundex-code'

export declare async function getPhonemeLibs(): Promise<{ soundex: soundex; doubleMetaphone: doubleMetaphone }>

Hey, the issue is still there.
Any updates on it?

@weswigham can we reopen this issue given that the rationale to close it (moduleResolution: node12 #45884) is no longer appliable to more recent moduleResolution modes ?

@abenhamdine use "moduleResolution": "node16" instead.

jekh commented

@xiaoxiangmoe Unfortunately, that doesn't solve the originally-reported issue of using import() of an ESM module into CommonJS.

Given that:
1. Node supports dynamic import() of ESM into CJS;
2. ^ is the main way to adopt ESM-only modules in existing CJS projects;
3. CommonJS is going to be around for years as the ecosystem slowly migrates to ESM; and
4. That same ESM migration is made much harder without dynamic import() of ESM into CommonJS projects

It seems like it would be an enormous help to have a flag to allow typescript targeting CommonJS to preserve dynamic import(). Whether on a project-wide basis ("never transpile my dynamic imports") or a call-site pragma ("don't transpile THIS dynamic import"), this really would be an extremely helpful feature.

+1 for reopening this issue.

EDIT: My apologies, "moduleResolution": "node16" does achieve the desired result of not transpiling dynamic import() while still targeting CommonJS. The errors I continue to have using ESM in CJS are related to type-only imports, not runtime dynamic import(). For example:

src/client.ts:4:26 - error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("got")' call instead.
  To convert this file to an ECMAScript module, change its file extension to '.mts' or create a local package.json file with `{ "type": "module" }`.

4 import type { Got } from "got"
                           ~~~~~

However, that doesn't appear to be related to the original issue.

Edit 2: For anyone interested, #49721 tracks the issue of type-only imports of ESM modules from CJS modules.

@abenhamdine use "moduleResolution": "node16" instead.

We are stuck on nodejs 14 due to an incompatibility of an old module.
Is modeResolution node16 possible with nodejs 14 ? Sounds like not but I will give a try, thx.

@abenhamdine use "moduleResolution": "node16" instead.

We are stuck on nodejs 14 due to an incompatibility of an old module. Is modeResolution node16 possible with nodejs 14 ? Sounds like not but I will give a try, thx.

From what I gathered in #48646, the decision to use node16 rather than node12 was due to top-level await not being supported in Node.js 12.x. So unless you or any of your dependencies use top-level await, you are probably fine.

@abenhamdineๆ”น็”จใ€‚"moduleResolution": "node16"

Why can't I use node16

What is your ts version

{
  "compileOnSave": true,
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node16",
    
  },
  
}

Y80 commented

tsconfig.json

"moduleResolution": "Node16"

It's OK!

IanVS commented

Note that using "moduleResolution": "Node16" may not be appropriate if you are using a bundler, and could cause you some pain in that case. See discussion in #50152 for a proposal of a more flexible moduleResolution for bundlers.

We are in 2023 and I am still having this issue.

{
  "compilerOptions": {
    "target": "es2021",
    "moduleResolution": "node",
    "module": "node16",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "strict": true,
    "lib": ["es2017"],
    "rootDir": "./src",
    "baseUrl": ".",
    "outDir": "./dist",
    "skipLibCheck": true,
    "strictNullChecks": true,
    "noImplicitAny": false
  },
  "exclude": ["node_modules", "**/test/**"]
}

With the config above
const { default: imageType } = await import('image-type')

still gets transpiled into:
const { default: imageType } = await Promise.resolve().then(() => __importStar(require('image-type')));

Please sort this out finally!

@codan84 change your moduleResolution to node16. Having it out-of-sync with your module option makes us not pick up some metadata that influences the transform we choose.

Why was this closed? It still doesn't work :-/.

vnues commented

We are in 2023 and I am still having this issue.

{
  "compilerOptions": {
    "target": "es2021",
    "moduleResolution": "node",
    "module": "node16",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "strict": true,
    "lib": ["es2017"],
    "rootDir": "./src",
    "baseUrl": ".",
    "outDir": "./dist",
    "skipLibCheck": true,
    "strictNullChecks": true,
    "noImplicitAny": false
  },
  "exclude": ["node_modules", "**/test/**"]
}

With the config above const { default: imageType } = await import('image-type')

still gets transpiled into: const { default: imageType } = await Promise.resolve().then(() => __importStar(require('image-type')));

Please sort this out finally!

you can solve it like this

const { default: imageType } = await eval("import('image-type')")

We are in 2023 and I am still having this issue.

{
  "compilerOptions": {
    "target": "es2021",
    "moduleResolution": "node",
    "module": "node16",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "strict": true,
    "lib": ["es2017"],
    "rootDir": "./src",
    "baseUrl": ".",
    "outDir": "./dist",
    "skipLibCheck": true,
    "strictNullChecks": true,
    "noImplicitAny": false
  },
  "exclude": ["node_modules", "**/test/**"]
}

With the config above const { default: imageType } = await import('image-type')
still gets transpiled into: const { default: imageType } = await Promise.resolve().then(() => __importStar(require('image-type')));
Please sort this out finally!

you can solve it like this

const { default: imageType } = await eval("import('image-type')")

I was struggling with this issue for weeks, esm modules kept breaking jest. For me, using await eval was the solution to this issue. Thanks a lot!

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?

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

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

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

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'.

I also had to enable "skipLibCheck": true:

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

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);
  }
}

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 ..."
...

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!!!

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

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