Skip to content

Commit d15a692

Browse files
devversiondylhunn
authored andcommitted
build: enable esModuleInterop in TypeScript compilations (angular#43431)
Enables the `esModuleInterop` for all TypeScript compilations in the project. This allows us to emit proper ESM-compatible code. e.g. consider the following import: ```ts import * as ts from 'typescript'; ``` This import currently will break at runtime in NodeJS because the `typescript` package is not shipping ESM. It's still a CommonJS module. ES modules are able to import from `typescript` though, using an import statement as above, but everything in `module.exports` is being exposed as the `default` named export. TypeScript at runtime does not have any other named exports, so for actual ESM compatibility, all of our imports need to be switched to: ``` import ts from 'typescript'; ``` The `esModuleInterop` option allows this to work even though the `d.ts` file of TS currently suggests that there are _only_ named exports. The TypeScript language service will now suggest the correct import form as shown above. It doesn't enforce that unfortunately, but this commit also adds a lint rule that enforces certain patterns so that we emit imports that are compatible with both ESM and CJS output (CJS still needed here since tests run with CJS devmode output still; this is a future project to switch that over to ESM!) PR Close angular#43431
1 parent 27535bf commit d15a692

File tree

8 files changed

+131
-3
lines changed

8 files changed

+131
-3
lines changed

packages/bazel/src/ngc-wrapped/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"compilerOptions": {
33
"lib": ["es5", "es2015.collection", "es2015.core"],
44
"types": ["node"],
5+
"esModuleInterop": true,
56
"downlevelIteration": true
67
}
78
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"compilerOptions": {
33
"lib": ["es5", "es2015.collection", "es2015.core"],
4-
"types": ["node", "jasmine"]
4+
"types": ["node", "jasmine"],
5+
"esModuleInterop": true
56
}
67
}

packages/core/schematics/tsconfig.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"noImplicitOverride": true,
55
"useUnknownInCatchVariables": false,
66
"noFallthroughCasesInSwitch": true,
7+
"esModuleInterop": true,
78
"strict": true,
89
"moduleResolution": "node",
910
"target": "es2019",
@@ -14,7 +15,7 @@
1415
"@angular/core": ["../"],
1516
"@angular/compiler": ["../../compiler"],
1617
"@angular/compiler-cli": ["../../compiler-cli"],
17-
"@angular/compiler-cli/private/*": ["../../compiler-cli/private/*"],
18+
"@angular/compiler-cli/private/*": ["../../compiler-cli/private/*"]
1819
}
1920
},
2021
"bazelOptions": {

packages/tsconfig-build.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"noImplicitAny": true,
1111
"noImplicitOverride": true,
1212
"strictNullChecks": true,
13+
"esModuleInterop": true,
1314
"useUnknownInCatchVariables": false,
1415
"strict": true,
1516
"strictPropertyInitialization": true,

packages/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"module": "es2020",
1111
"strict": true,
1212
"moduleResolution": "node",
13+
"esModuleInterop": true,
1314
"strictNullChecks": true,
1415
"strictPropertyInitialization": true,
1516
"outDir": "../dist/all/@angular",

tools/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"emitDecoratorMetadata": true,
66
"experimentalDecorators": true,
77
"module": "commonjs",
8+
"esModuleInterop": true,
89
"moduleResolution": "node",
910
"outDir": "../dist/tools/",
1011
"noImplicitAny": true,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {RuleFailure, WalkContext} from 'tslint/lib';
10+
import {AbstractRule} from 'tslint/lib/rules';
11+
import ts from 'typescript';
12+
13+
// TODO(devversion): move this rule into dev-infra.
14+
15+
const noNamedExportsError =
16+
'Named import is not allowed. The module does not expose named exports when ' +
17+
'imported in an ES module. Use a default import instead.';
18+
19+
const noDefaultExportError =
20+
'Default import is not allowed. The module does not expose a default export at ' +
21+
'runtime. Use a named import instead.';
22+
23+
interface RuleOptions {
24+
/**
25+
* List of modules without any named exports that NodeJS can statically detect when the
26+
* CommonJS module is imported from ESM. Node only exposes named exports which are
27+
* statically discoverable: https://nodejs.org/api/esm.html#esm_import_statements.
28+
*/
29+
noNamedExports?: string[];
30+
/**
31+
* List of modules which appear to have named exports in the typings but do
32+
* not have any at runtime due to NodeJS not being able to discover these
33+
* through static analysis: https://nodejs.org/api/esm.html#esm_import_statements.
34+
* */
35+
noDefaultExport?: string[];
36+
/**
37+
* List of modules which are always incompatible. The rule allows for a custom
38+
* message to be provided when it discovers an import to such a module.
39+
*/
40+
incompatibleModules?: Record<string, string>;
41+
}
42+
43+
/**
44+
* Rule that blocks named imports from being used for certain configured module
45+
* specifiers. This is helpful for enforcing an ESM-compatible interop with CommonJS
46+
* modules which do not expose named bindings at runtime.
47+
*
48+
* For example, consider the `typescript` module. It does not statically expose named
49+
* exports even though the type definition suggests it. An import like the following
50+
* will break at runtime when the `typescript` CommonJS module is imported inside an ESM.
51+
*
52+
* ```
53+
* import * as ts from 'typescript';
54+
* console.log(ts.SyntaxKind.CallExpression); // `SyntaxKind is undefined`.
55+
* ```
56+
*
57+
* More details here: https://nodejs.org/api/esm.html#esm_import_statements.
58+
*/
59+
export class Rule extends AbstractRule {
60+
override apply(sourceFile: ts.SourceFile): RuleFailure[] {
61+
const options = this.getOptions().ruleArguments[0];
62+
return this.applyWithFunction(sourceFile, (ctx) => visitNode(sourceFile, ctx, options));
63+
}
64+
}
65+
66+
function visitNode(node: ts.Node, ctx: WalkContext, options: RuleOptions) {
67+
if (options.incompatibleModules && ts.isImportDeclaration(node)) {
68+
const specifier = node.moduleSpecifier as ts.StringLiteral;
69+
const failureMsg = options.incompatibleModules[specifier.text];
70+
71+
if (failureMsg !== undefined) {
72+
ctx.addFailureAtNode(node, failureMsg);
73+
return;
74+
}
75+
}
76+
77+
if (options.noNamedExports && isNamedImportToDisallowedModule(node, options.noNamedExports)) {
78+
ctx.addFailureAtNode(node, noNamedExportsError);
79+
}
80+
81+
if (options.noDefaultExport && isDefaultImportToDisallowedModule(node, options.noDefaultExport)) {
82+
ctx.addFailureAtNode(node, noDefaultExportError);
83+
}
84+
85+
ts.forEachChild(node, (node) => visitNode(node, ctx, options));
86+
}
87+
88+
function isNamedImportToDisallowedModule(node: ts.Node, disallowed: string[]): boolean {
89+
if (!ts.isImportDeclaration(node) || node.importClause === undefined) {
90+
return false;
91+
}
92+
const specifier = node.moduleSpecifier as ts.StringLiteral;
93+
return !!node.importClause.namedBindings && disallowed.includes(specifier.text);
94+
}
95+
96+
function isDefaultImportToDisallowedModule(node: ts.Node, disallowed: string[]) {
97+
if (!ts.isImportDeclaration(node) || node.importClause === undefined) {
98+
return false;
99+
}
100+
const specifier = node.moduleSpecifier as ts.StringLiteral;
101+
102+
return node.importClause.name !== undefined && disallowed.includes(specifier.text);
103+
}

tslint.json

+20-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,26 @@
1313
// Custom rules written in TypeScript.
1414
"require-internal-with-underscore": true,
1515
"no-implicit-override-abstract": true,
16-
16+
"validate-import-for-esm-cjs-interop": [true, {
17+
// The following CommonJS modules have type definitions that suggest the existence of
18+
// named exports. This is not true at runtime when imported from an ES module (because
19+
// the ESM interop only exposes statically-discoverable named exports). Instead
20+
// default imports should be used to ensure compatibility with both ESM or CommonJS.
21+
"noNamedExports": ["typescript", "minimist", "magic-string", "semver", "yargs", "glob", "cluster", "convert-source-map"],
22+
// The following CommonJS modules appear to have a default export available (due to the `esModuleInterop` flag),
23+
// but at runtime with CJS (e.g. for devmode output/tests) there is no default export as these modules set
24+
// `__esModule`. This does not match with what happens in ESM NodeJS runtime where NodeJS exposes
25+
// `module.exports` as `export default`. Instead, named exports should be used for compat with CJS/ESM.
26+
"noDefaultExport": [],
27+
// List of modules which are incompatible and should never be imported at all.
28+
"incompatibleModules": {
29+
// `@babel/core` and `@babel/types` suggest named exports which do not exist at runtime within ESM
30+
// (as these named exports are not statically discoverable by NodeJS). At the same time, these modules
31+
// set `__esModule` and the default import does not exist for CJS at runtime (e.g. breaking tests).
32+
"@babel/core": "This module is incompatible with the ESM/CJS interop. Use the custom interop file.",
33+
"@babel/types": "This module is incompatible with the ESM/CJS interop. Use the custom interop file and import the `types` namespace."
34+
}
35+
}],
1736
"eofline": true,
1837
"file-header": [
1938
true,

0 commit comments

Comments
 (0)