ES module issues
Cloudpack internally generates ES module bundles for each package, but there are some compatibility issues caused by different input module formats, and by differences between TypeScript's initial implementation of the import/export keywords and the final ESM spec.
Unless the esModuleInterop TS settings was previously enabled in a repo, this can cause issues with onboarding Cloudpack, especially if the code was previously bundled monolithically with Webpack (which hides many of the issues).
Module formats and history
ECMAScript is the language specification for all versions of JavaScript (as used by both browsers and Node), but "ES modules" specifically refers to the module format introduced in the 2015 version of the specification.
Before the ES module spec was finalized, Node, browsers, and TypeScript had taken different approaches to implementing or simulating modules. ES modules are usable today in both browsers and Node, but it took nearly a decade to reach that point (and there are still some nuances), so some of the previous formats are still widely used and worth understanding.
See our docs on modules to learn more, but briefly:
- ES modules use the
importandexportkeywords. They're the new standard for both browsers and Node. - CommonJS modules are the old format for Node. They use
require()for imports andmodule.exports = ...for exports. - TypeScript code typically uses
import/exportsyntax similar (not identical) to ESM, but it can be configured to transpile to any other module or script type.
TypeScript issues
TypeScript's module setting allows transpilation to a variety of output file/module formats. However, there are some quirks with its initial handling of the import/export keywords in TS source files that continue to cause headaches.
Prior to the ES2015 specification being finalized, TypeScript introduced a slightly different implementation of the import and export keywords. You can choose between the original approach and the spec-compliant approach with the esModuleInterop flag. The esModuleInterop docs explain the differences (follow the link for examples of JS output with each mode):
[TypeScript's original approach] (with
esModuleInteropfalse or not set) treats CommonJS/AMD/UMD modules similar to ES modules. In doing this, there are two parts in particular which turned out to be flawed assumptions:
- a namespace import like
import * as moment from "moment"acts the same asconst moment = require("moment")- a default import like
import moment from "moment"acts the same asconst moment = require("moment").defaultThis mismatch causes these two issues:
- The ES modules spec states that a namespace import (
import * as x) can only be an object. By having TypeScript treat it the same as= require("x"), it allowed for the import to be treated as a function and be callable. That’s not valid according to the spec.- While accurate to the ES modules spec, most libraries with CommonJS/AMD/UMD modules didn’t conform as strictly as TypeScript’s implementation.
Turning on
esModuleInteropwill fix both of these problems in the code transpiled by TypeScript. The first changes the behavior in the compiler, the second is fixed by two new helper functions which provide a shim to ensure compatibility in the emitted JavaScript. (see link above for demo)
TypeScript also originally supported syntax like import foo = require('foo') which is not valid per the spec and may not be supported by stricter bundlers.
Although the esModuleInterop setting has existed for at least a decade now, it wasn't always used in repos for various reasons, which can cause some significant migration headaches. There are also certain packages which must be imported in different ways depending on whether each tool assumes ESM spec-compliant syntax or not.
Related reading
- More context on ESM vs CJS formats and history
- Deep dive about CJS/ESM interop problems -- includes some of the problems discussed here, plus additional issues
- TypeScript's module options and syntax reference
esModuleInteropandallowSyntheticDefaultImportsTypeScript settings (also discussed below)- Node's modules reference
Interop and migration issues
This section details some issues you might encounter when migrating from CommonJS output to ES module output, and when enabling the esModuleInterop flag for TypeScript.
Importing CJS that assigns a class or function to module.exports
When does this issue apply?
This issue applies to certain imports in a repo's TypeScript code, if the TS setting esModuleInterop is false or unset.
It only applies when using import * syntax for certain older CommonJS packages such as classnames and zen-observable which assign a function or class to module.exports, e.g. module.exports = someFunc. (Using import * is fine if the export is an object.)
What this looks like in the dependency:
// classnames JS
module.exports = function classNames() {} // or class Observable for zen-observable
// classnames types
export = classNamesIn Cloudpack's output, you can identify packages and imports using this pattern from warnings like this:
Calling "classnames" will crash at run-time because it's an import namespace object, not a function
At runtime, the calling code will crash as noted, and you'll probably see an error like this in the browser console:
Uncaught TypeError: classnames is not a function
What's the issue?
By default (without esModuleInterop), for this type of package, TypeScript requires using namespace imports (import *) in the consuming code:
// By default, TypeScript treats this like CJS `const classNames = require("classnames")`.
// This syntax is not valid in other tools.
import * as classNames from 'classnames'
const myClass = classNames('foo', 'bar')However, the official ES Modules spec (implemented by Node, modern browsers, and certain newer build tools) requires using default imports:
// Also valid in TypeScript with esModuleInterop
import classNames from 'classNames'
const myClass = classNames('foo', 'bar')This mismatch becomes a problem when trying to use newer tools such as ori/esbuild (used internally by Cloudpack by default) or swc. Those tools require the default import syntax for affected packages, but TS without extra settings will error on default imports. (Webpack is very permissive and can usually handle either syntax.)
See above for the historical reasons for this difference.
Ideal fix: enable esModuleInterop
TS has a setting esModuleInterop which supports and enforces the new default import syntax for affected packages:
// TS + esModuleInterop
import classNames from 'classnames'
const myClass = classNames('foo', 'bar')
// Now if you did `import * as classNames`, there would be an error when trying to call classNames():
// "a namespace-style import cannot be called or constructed"
// Partial JS output with module: commonjs (used by Jest)
var __importDefault = function (mod) {
return mod && mod.__esModule ? mod : { default: mod }
}
const classnames_1 = __importDefault(require('classnames'))
const a = classnames_1.default()Unfortunately, enabling this setting in a large repo may not be straightforward:
- It has the side effect of making properties of all imported modules immutable. If your tests were previously using CJS output internally (typical for older Jest setups), this will require manual fixes to mock/spy approaches in tests. It may also cause other issues.
- The two import styles are mutually incompatible with the same TS settings, so it's not possible to enable this only for non-test code (since the test runner must transpile the TS code being tested).
- It may or may not be possible to enable only for certain packages, depending on how cross-package imports are resolved (it won't work if the imports resolve to source files, as opposed to previously-transpiled output files).
There's also a TS setting allowSyntheticDefaultImports. It makes TS accept the default imports but not add the __importDefault helper when transpiling. This will work with ESM output This works with ESM output, used by Webpack/ts-loader. However, default imports will cause runtime errors in CJS output, used by Jest tests, since default doesn't exist.
(Despite this potential for causing runtime errors, allowSyntheticDefaultImports must be enabled due to cascading type effects of other libraries enabling esModuleInterop: e.g. if the repo depends on a React component library which does import React from "react", it would cause compiler errors when those components are consumed in the repo unless allowSyntheticDefaultImports is enabled.)
Patch workaround (not recommended)
The number of legacy CJS dependencies assigning a function or class to module.exports is typically fairly small. So in a very large repo where it's extremely time-consuming to fix all the test issues and other concerns blocking esModuleInterop, it may be possible to use allowSyntheticDefaultImports + selective local patches (via patch-package or another tool) to remove old TS-style import * of problem packages and unblock Cloudpack integration.
This ONLY works if the repo does NOT publish packages to npm or another registry (or if all dependencies are bundled), since the patches would be missing for any consumers of the published packages. It also creates debt, so fixing the issues properly and enabling esModuleInterop is preferable if at all possible.
Expand for patching details and examples
Basically, what you need to do is update module.exports to add the function or class as a default property, and set __esModule: true. There may be some variation depending on the details of the exports setup, but here's an example for classnames:
diff --git a/node_modules/classnames/index.js b/node_modules/classnames/index.js
index c8f59dd..1b861bf 100644
--- a/node_modules/classnames/index.js
+++ b/node_modules/classnames/index.js
@@ -39,8 +39,9 @@
if (typeof module !== 'undefined' && module.exports) {
module.exports = classNames;
+ module.exports.default = classNames;
+ Object.defineProperty(module.exports, '__esModule', { value: true });
} else if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {For the types, change export = whatever to export default whatever. Example for @types/classnames:
diff --git a/node_modules/@types/classnames/index.d.ts b/node_modules/@types/classnames/index.d.ts
index a0e0e08..d6b94be 100644
--- a/node_modules/@types/classnames/index.d.ts
+++ b/node_modules/@types/classnames/index.d.ts
@@ -14,5 +14,5 @@ import { ClassValue, ClassNamesExport } from './types';
declare const classNames: ClassNamesExport;
-export = classNames;
+export default classNames;
export as namespace classNames;Reference: Behavior of import styles with various tools and settings
For the scenario of importing CJS that assigns a class or function to module.exports, it can be hard to remember which permutations of import syntax, settings, tools, and runtime context work together or not. The table in this section outlines the compile time and runtime behavior for various cases. Notes:
- Package
"foo"doesmodule.exports = function foo() {} - The right two headers are the code to be run or transpiled:
import * as foo from "foo"is the traditional TS-style importimport foo from "foo"is the ESM-compliant import
- CJS/ESM below refers to the output format (
modulesetting for TS) - In the TypeScript (TS) cases under "Tools and settings", "none" means both
allowSyntheticDefaultImportsandesModuleInteropare false/unset
| Tool and settings | import * as foo from "foo" | import foo from "foo" |
|---|---|---|
| TS with CJS output | ||
| TS: CJS, none | ✅ Output: const foo = require("foo") | ❌ compiler error (at import) |
TS: CJS, allowSynthetic DefaultImports | ✅ Output: const foo = require("foo") | ❌❗️ runtime error only: const foo_1 = require("foo"); foo_1.default() ("default is not defined") |
TS: CJS, esModuleInterop | ❌ compiler error (at usage: "a namespace-style import cannot be called or constructed") | ✅ Output: const foo_1 = __importDefault( require("foo")); foo_1.default() |
| TS with ESM output | ||
| TS: ESM, none | ❌❗️ runtime error only (emits import *; runtime error since foo is not a function) | ❌ compiler error (at import) |
TS: ESM, allowSynthetic DefaultImports | ❌❗️ runtime error only (emits import *; runtime error since foo is not a function) | ✅ emits default import, which works when run in ESM context |
TS: ESM, esModuleInterop | ❌ compiler error (at usage: "a namespace-style import cannot be called or constructed") | ✅ emits default import, which works when run in ESM context |
| Other tools | ||
| esbuild (only does ESM output) | ❌ bundler error | ✅ |
| swc: ESM | ❌❗️ runtime error only (emits import *; runtime error since foo is not a function) | ✅ emits default import, which works when run in ESM context |
| swc: CJS | ❌❗️ runtime error only (emits _interop_require_wildcard( require("foo")); runtime error since namespace is not a function) | ✅ const _foo = _interop_require_wildcard( require("foo")); _foo.default() (helper adds default property) |
| native JS ESM code (run in Node or browser) | ❌ error | ✅ |
Mock/spy pattern changes for esModuleInterop with CJS output
INFO
This section is relevant if your code has been transpiling to CJS using TypeScript, without the esModuleInterop flag enabled.
One of the features of the ES modules spec is that module properties are immutable. If you're using TypeScript for transpilation to CJS with esModuleInterop enabled, it will also make module properties immutable to follow ESM behavior. For example, this is now an error:
// TS transpilation to CJS with esModuleInterop
import * as fooModule from './foo'
// This is now a runtime error
fooModule.foo = () => undefined // do mock stuffThis means that various mock/spy approaches will no longer work, which may require manual fixes in tests.
Some of these issues can be fixed with find/replace, but in other cases it's less straightforward or not feasible to automate. So you'll likely need to just enable esModuleInterop, fix any imports, try running tests, and manually fix any breaks.
The examples below use Jest-specific syntax, but similar concepts will likely apply for other test runners.
Global spyOn
This only applies if you're using the global spyOn function (shipped with Jest but actually provided by Jasmine).
Global (jasmine) spyOn internally assigns to module properties and should be replaced with jest.spyOn. Note that jasmine spyOn overwrites the original by default, whereas jest.spyOn also calls the original function (and the mocking APIs are different), so some usage updates are required.
// Override actual implementation
/* old */ spyOn(foo, 'bar')
/* new */ jest.spyOn(foo, 'bar').mockImplementation()
// Call the original implementation and spy on it
/* old */ spyOn(foo, 'bar').and.callThrough()
/* new */ jest.spyOn(foo, 'bar')Assigning to module properties
Assigning to properties of a module imported as a namespace (import * as foo) also won't work, and should be replaced with jest.spyOn or jest.mock():
import * as foo from "foo";
// OLD
foo.bar = /* something */
// NEW
jest.spyOn(foo, "bar").mockImplementation(/*...*/);
// or mock all functions from the module
jest.mock("foo");
(foo.bar as jest.Mock).mockImplementation(/*...*/);
// or manually mock the module (the function can be mocked inline or later)
jest.mock("foo", () => ({ bar: /* something */ }));After this update, if you're getting errors like TypeError: Cannot set property foo of [object Object] which has only a getter, see the next section.
Spying on module properties
If you're using Jest and have it configured to transpile your code to CJS, you may get errors like this when attempting to use jest.spyOn on a module property:
import * as fooModule from './foo'
// TypeError: Cannot set property foo of [object Object] which has only a getter
jest.spyOn(fooModule, 'foo').mockImplementation(/*...*/)To work around this, you can override Object.defineProperty to make jest.spyOn work for module properties as transpiled by TS. Include this file in Jest's setupFiles config (it must run before environment setup):
// Hack: https://github.com/facebook/jest/issues/6914
// This must be set up *before* the test environment so that it's possible to spy on/mock properties
// of modules that are initially loaded during the test environment setup.
const { defineProperty } = Object
Object.defineProperty = function defineProp(object, name, meta) {
if (meta.get && !meta.configurable) {
// it might be an ES6 exports object
return defineProperty(object, name, {
...meta,
configurable: true, // prevent freezing
})
}
return defineProperty(object, name, meta)
}Implicit assignment to module properties
This pattern relies on an artifact of TypeScript's transpilation strategy for CommonJS output. It should never have been used to begin with, and will also no longer work due to immutable ES module imports. Replace with one of the approaches above.
// OLD
import { bar } from "foo";
(bar as any) = /* something */
(bar as jest.Mock) = jest.fn(/*...*/);
// in test, or in code called by the test
bar();
// This ONLY worked because TS transpiles the code to CJS as follows:
const foo_1 = require("foo");
foo_1.bar = /* something */
foo_1.bar();
// new: same as approach for explicit assignment to module properties