angular/tools/tslint/validateImportForEsmCjsInteropRule.ts
Paul Gschwendtner d15a692789 build: enable esModuleInterop in TypeScript compilations (#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 #43431
2021-10-01 18:28:45 +00:00

103 lines
3.9 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {RuleFailure, WalkContext} from 'tslint/lib';
import {AbstractRule} from 'tslint/lib/rules';
import ts from 'typescript';
// TODO(devversion): move this rule into dev-infra.
const noNamedExportsError =
'Named import is not allowed. The module does not expose named exports when ' +
'imported in an ES module. Use a default import instead.';
const noDefaultExportError =
'Default import is not allowed. The module does not expose a default export at ' +
'runtime. Use a named import instead.';
interface RuleOptions {
/**
* List of modules without any named exports that NodeJS can statically detect when the
* CommonJS module is imported from ESM. Node only exposes named exports which are
* statically discoverable: https://nodejs.org/api/esm.html#esm_import_statements.
*/
noNamedExports?: string[];
/**
* List of modules which appear to have named exports in the typings but do
* not have any at runtime due to NodeJS not being able to discover these
* through static analysis: https://nodejs.org/api/esm.html#esm_import_statements.
* */
noDefaultExport?: string[];
/**
* List of modules which are always incompatible. The rule allows for a custom
* message to be provided when it discovers an import to such a module.
*/
incompatibleModules?: Record<string, string>;
}
/**
* Rule that blocks named imports from being used for certain configured module
* specifiers. This is helpful for enforcing an ESM-compatible interop with CommonJS
* modules which do not expose named bindings at runtime.
*
* For example, consider the `typescript` module. It does not statically expose named
* exports even though the type definition suggests it. An import like the following
* will break at runtime when the `typescript` CommonJS module is imported inside an ESM.
*
* ```
* import * as ts from 'typescript';
* console.log(ts.SyntaxKind.CallExpression); // `SyntaxKind is undefined`.
* ```
*
* More details here: https://nodejs.org/api/esm.html#esm_import_statements.
*/
export class Rule extends AbstractRule {
override apply(sourceFile: ts.SourceFile): RuleFailure[] {
const options = this.getOptions().ruleArguments[0];
return this.applyWithFunction(sourceFile, (ctx) => visitNode(sourceFile, ctx, options));
}
}
function visitNode(node: ts.Node, ctx: WalkContext, options: RuleOptions) {
if (options.incompatibleModules && ts.isImportDeclaration(node)) {
const specifier = node.moduleSpecifier as ts.StringLiteral;
const failureMsg = options.incompatibleModules[specifier.text];
if (failureMsg !== undefined) {
ctx.addFailureAtNode(node, failureMsg);
return;
}
}
if (options.noNamedExports && isNamedImportToDisallowedModule(node, options.noNamedExports)) {
ctx.addFailureAtNode(node, noNamedExportsError);
}
if (options.noDefaultExport && isDefaultImportToDisallowedModule(node, options.noDefaultExport)) {
ctx.addFailureAtNode(node, noDefaultExportError);
}
ts.forEachChild(node, (node) => visitNode(node, ctx, options));
}
function isNamedImportToDisallowedModule(node: ts.Node, disallowed: string[]): boolean {
if (!ts.isImportDeclaration(node) || node.importClause === undefined) {
return false;
}
const specifier = node.moduleSpecifier as ts.StringLiteral;
return !!node.importClause.namedBindings && disallowed.includes(specifier.text);
}
function isDefaultImportToDisallowedModule(node: ts.Node, disallowed: string[]) {
if (!ts.isImportDeclaration(node) || node.importClause === undefined) {
return false;
}
const specifier = node.moduleSpecifier as ts.StringLiteral;
return node.importClause.name !== undefined && disallowed.includes(specifier.text);
}