Skip to content

Commit

Permalink
Add flag to emit createRequire matching TS nodenext behavior (alangpi…
Browse files Browse the repository at this point in the history
…erce#728)

Progress toward alangpierce#726

This is currently an opt-in flag handling a nuance in how TS transpiles imports
like these:
```ts
import foo = require('foo');
```
In the new nodenext mode when targeting ESM, TS now transforms this code to use
`createRequire`. The change is described here:
https://devblogs.microsoft.com/typescript/announcing-typescript-4-7/#commonjs-interoperability

This PR adds a flag `injectCreateRequireForImportRequire` to enable this
different behavior. I'm gating this behind a flag out of caution, though it's
worth noting that TS gives an error when using this syntax and targeting
module esnext, so it will likely be safe to switch to this new emit strategy as
the default behavior in the future.

As I understand it, the main benefit of this change over explicit `createRequire`
is that it allows a single codebase to be transpiled to Node ESM and Node CJS
while using this syntax. A downside is that `createRequire` is Node-specific and
needs special support from bundlers, but it looks like webpack can recognize the
pattern. In most situations, real ESM `import` syntax is probably preferable,
but this syntax makes it possible to force the use of CJS.

The ts-node transpiler plugin system expects transpilers to have this mode as an
option, so this is a step closer to having a compliant ts-node plugin.
  • Loading branch information
alangpierce authored and 1Lighty committed Aug 14, 2022
1 parent 38db7dc commit 218b152
Show file tree
Hide file tree
Showing 8 changed files with 89 additions and 1 deletion.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ transforms are available:
the same way as [babel-plugin-jest-hoist](https://github.com/facebook/jest/tree/master/packages/babel-plugin-jest-hoist).
Does not validate the arguments passed to `jest.mock`, but the same rules still apply.

When the `imports` transform is *not* specified (i.e. when targeting ESM), the
`injectCreateRequireForImportRequire` option can be specified to transform TS
`import foo = require("foo");` in a way that matches the
[TypeScript 4.7 behavior](https://devblogs.microsoft.com/typescript/announcing-typescript-4-7/#commonjs-interoperability)
with `module: nodenext`.

These newer JS features are transformed by default:

* [Optional chaining](https://github.com/tc39/proposal-optional-chaining): `a?.b`
Expand Down
10 changes: 10 additions & 0 deletions src/HelperManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type NameManager from "./NameManager";

const HELPERS: {[name: string]: string} = {
require: `
import {createRequire as CREATE_REQUIRE_NAME} from "module";
const require = CREATE_REQUIRE_NAME(import.meta.url);
`,
interopRequireWildcard: `
function interopRequireWildcard(obj) {
if (obj && obj.__esModule) {
Expand Down Expand Up @@ -125,6 +129,7 @@ const HELPERS: {[name: string]: string} = {

export class HelperManager {
helperNames: {[baseName in keyof typeof HELPERS]?: string} = {};
createRequireName: string | null = null;
constructor(readonly nameManager: NameManager) {}

getHelperName(baseName: keyof typeof HELPERS): string {
Expand Down Expand Up @@ -155,6 +160,11 @@ export class HelperManager {
"ASYNC_OPTIONAL_CHAIN_NAME",
this.helperNames.asyncOptionalChain!,
);
} else if (baseName === "require") {
if (this.createRequireName === null) {
this.createRequireName = this.nameManager.claimFreeName("_createRequire");
}
helperCode = helperCode.replace(/CREATE_REQUIRE_NAME/g, this.createRequireName);
}
if (helperName) {
resultCode += " ";
Expand Down
1 change: 1 addition & 0 deletions src/Options-gen-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const Options = t.iface([], {
disableESTransforms: t.opt("boolean"),
addUseStrict: t.opt("boolean"),
preserveDynamicImport: t.opt("boolean"),
injectCreateRequireForImportRequire: t.opt("boolean"),
});

const exportedTypeSuite: t.ITypeSuite = {
Expand Down
11 changes: 11 additions & 0 deletions src/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ export interface Options {
* expressions into require() calls.
*/
preserveDynamicImport?: boolean;
/**
* Only relevant when targeting ESM (i.e. when the imports transform is *not*
* specified). This flag changes the behavior of TS require imports:
*
* import Foo = require("foo");
*
* to import createRequire, create a require function, and use that function.
* This is the TS behavior with module: nodenext and makes it easier for the
* same code to target ESM and CJS.
*/
injectCreateRequireForImportRequire?: boolean;
}

export function validateOptions(options: Options): void {
Expand Down
17 changes: 16 additions & 1 deletion src/transformers/ESMImportTransformer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {HelperManager} from "../HelperManager";
import type {Options} from "../index";
import type NameManager from "../NameManager";
import {ContextualKeyword} from "../parser/tokenizer/keywords";
Expand All @@ -21,10 +22,12 @@ import Transformer from "./Transformer";
export default class ESMImportTransformer extends Transformer {
private nonTypeIdentifiers: Set<string>;
private declarationInfo: DeclarationInfo;
private injectCreateRequireForImportRequire: boolean;

constructor(
readonly tokens: TokenProcessor,
readonly nameManager: NameManager,
readonly helperManager: HelperManager,
readonly reactHotLoaderTransformer: ReactHotLoaderTransformer | null,
readonly isTypeScriptTransformEnabled: boolean,
options: Options,
Expand All @@ -36,6 +39,7 @@ export default class ESMImportTransformer extends Transformer {
this.declarationInfo = isTypeScriptTransformEnabled
? getDeclarationInfo(tokens)
: EMPTY_DECLARATION_INFO;
this.injectCreateRequireForImportRequire = Boolean(options.injectCreateRequireForImportRequire);
}

process(): boolean {
Expand Down Expand Up @@ -109,8 +113,19 @@ export default class ESMImportTransformer extends Transformer {
if (this.isTypeName(importName)) {
// If this name is only used as a type, elide the whole import.
elideImportEquals(this.tokens);
} else if (this.injectCreateRequireForImportRequire) {
// We're using require in an environment (Node ESM) that doesn't provide
// it as a global, so generate a helper to import it.
// import -> const
this.tokens.replaceToken("const");
// Foo
this.tokens.copyToken();
// =
this.tokens.copyToken();
// require
this.tokens.replaceToken(this.helperManager.getHelperName("require"));
} else {
// Otherwise, switch `import` to `const`.
// Otherwise, just switch `import` to `const`.
this.tokens.replaceToken("const");
}
return true;
Expand Down
1 change: 1 addition & 0 deletions src/transformers/RootTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default class RootTransformer {
new ESMImportTransformer(
tokenProcessor,
this.nameManager,
this.helperManager,
reactHotLoaderTransformer,
transforms.includes("typescript"),
options,
Expand Down
2 changes: 2 additions & 0 deletions test/prefixes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const JSX_PREFIX = 'const _jsxFileName = "";';
export const CREATE_REQUIRE_PREFIX = ` import {createRequire as _createRequire} from "module"; \
const _require = _createRequire(import.meta.url);`;
export const IMPORT_DEFAULT_PREFIX = ` function _interopRequireDefault(obj) { \
return obj && obj.__esModule ? obj : { default: obj }; }`;
export const IMPORT_WILDCARD_PREFIX = ` function _interopRequireWildcard(obj) { \
Expand Down
42 changes: 42 additions & 0 deletions test/typescript-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {Options} from "../src/Options";
import {
CREATE_REQUIRE_PREFIX,
CREATE_STAR_EXPORT_PREFIX,
ESMODULE_PREFIX,
IMPORT_DEFAULT_PREFIX,
Expand Down Expand Up @@ -893,6 +894,47 @@ describe("typescript transform", () => {
);
});

it("ignores injectCreateRequireForImportRequire when targeting CJS", () => {
assertTypeScriptResult(
`
import a = require('a');
console.log(a);
`,
`"use strict";
const a = require('a');
console.log(a);
`,
{injectCreateRequireForImportRequire: true},
);
});

it("preserves import = require by default when targeting ESM", () => {
assertTypeScriptESMResult(
`
import a = require('a');
console.log(a);
`,
`
const a = require('a');
console.log(a);
`,
);
});

it("transforms import = require when targeting ESM and injectCreateRequireForImportRequire is enabled", () => {
assertTypeScriptESMResult(
`
import a = require('a');
console.log(a);
`,
`${CREATE_REQUIRE_PREFIX}
const a = _require('a');
console.log(a);
`,
{injectCreateRequireForImportRequire: true},
);
});

it("allows this types in functions", () => {
assertTypeScriptResult(
`
Expand Down

0 comments on commit 218b152

Please sign in to comment.