Repository: alangpierce/sucrase (ts)

Issue:
support type modifers on import names in Typescript
I'm guessing this needs parser updates since it's new syntax but TS 4.5 introduced: https://devblogs.microsoft.com/typescript/announcing-typescript-4-5/#type-on-import-names

Pull request description:
Add support for TS 4.5 import type modifiers
Fixes #668

Details on the syntax are described here:
https://devblogs.microsoft.com/typescript/announcing-typescript-4-5/#type-on-import-names

This was implemented in Babel here: https://github.com/babel/babel/pull/13802

In terms of behavior, the type syntax is *almost* a no-op in valid code; we
already scan the file to see which identifiers have been used in a value or type
context and remove type imports automatically, and this PR doesn't change that.
However, there are a few subtle differences:
* When targeting commonjs, if an imported name has a `type` modifier and is used
  in a value context, we do *not* transform the syntax to access the module like
  we did before. For example, this comes up if accessing a global value with the
  same name as an imported type.
* In both CJS and ESM targets, code that imports and then re-exports a type
  needs to have that export elided. Previously, this was a situation where TS
  doesn't know whether the name is a type or not, so it included the export as a
  value.

Right now, there is no equivalent to `--preserveValueImports`, but this could be
added in the future. It *almost* would turn the TS transform into a purely
syntax-level transform without the need for scope analysis and variable name
tracking (thus simplifying and speeding up Sucrase if it became the standard
approach), except that even with that flag set, type-only exports are still
elided.

In terms of implementation details, the Babel parser details were a bit
AST-focused and didn't need to deal with `IdentifierRole`s, so I re-implemented
parsing using a simpler approach, which was also useful in the transform step.

It turns out that in both Flow and TS, you can parse an import or export
specifier by accepting anywhere from 1 to 4 identifiers, and the number of
identifiers is enough to exactly determine the syntax. This is more reliable
than matching on the names `type` (or `typeof`) and `as` because those are
contextual keywords that might be valid identifiers.

In the transform step, there were 5 places where we were manually walking
import/export specifiers, so it seemed best to unify that parsing into a shared
utility. This potentially has some performance cost due to the object
allocations, but it's likely to be very minor and could potentially be improved
later.

Patch:
diff --git a/src/CJSImportProcessor.ts b/src/CJSImportProcessor.ts
index 3b948c2..42ecc06 100644
--- a/src/CJSImportProcessor.ts
+++ b/src/CJSImportProcessor.ts
@@ -5,6 +5,7 @@ import {isDeclaration} from "./parser/tokenizer";
 import {ContextualKeyword} from "./parser/tokenizer/keywords";
 import {TokenType as tt} from "./parser/tokenizer/types";
 import type TokenProcessor from "./TokenProcessor";
+import getImportExportSpecifierInfo from "./util/getImportExportSpecifierInfo";
 import {getNonTypeIdentifiers} from "./util/getNonTypeIdentifiers";
 
 interface NamedImport {
@@ -359,31 +360,15 @@ export default class CJSImportProcessor {
         break;
       }
 
-      // Flow type imports should just be ignored.
-      let isTypeImport = false;
-      if (
-        (this.tokens.matchesContextualAtIndex(index, ContextualKeyword._type) ||
-          this.tokens.matches1AtIndex(index, tt._typeof)) &&
-        this.tokens.matches1AtIndex(index + 1, tt.name) &&
-        !this.tokens.matchesContextualAtIndex(index + 1, ContextualKeyword._as)
-      ) {
-        isTypeImport = true;
-        index++;
+      const specifierInfo = getImportExportSpecifierInfo(this.tokens, index);
+      index = specifierInfo.endIndex;
+      if (!specifierInfo.isType) {
+        namedImports.push({
+          importedName: specifierInfo.leftName,
+          localName: specifierInfo.rightName,
+        });
       }
 
-      const importedName = this.tokens.identifierNameAtIndex(index);
-      let localName;
-      index++;
-      if (this.tokens.matchesContextualAtIndex(index, ContextualKeyword._as)) {
-        index++;
-        localName = this.tokens.identifierNameAtIndex(index);
-        index++;
-      } else {
-        localName = importedName;
-      }
-      if (!isTypeImport) {
-        namedImports.push({importedName, localName});
-      }
       if (this.tokens.matches2AtIndex(index, tt.comma, tt.braceR)) {
         index += 2;
         break;
diff --git a/src/parser/plugins/typescript.ts b/src/parser/plugins/typescript.ts
index 5e66ed7..48d3641 100644
--- a/src/parser/plugins/typescript.ts
+++ b/src/parser/plugins/typescript.ts
@@ -1,5 +1,6 @@
 import {
   eat,
+  IdentifierRole,
   lookaheadType,
   lookaheadTypeAndKeyword,
   match,
@@ -1256,6 +1257,82 @@ export function tsTryParseExport(): boolean {
   }
 }
 
+/**
+ * Parse a TS import specifier, which may be prefixed with "type" and may be of
+ * the form `foo as bar`.
+ *
+ * The number of identifier-like tokens we see happens to be enough to uniquely
+ * identify the form, so simply count the number of identifiers rather than
+ * matching the words `type` or `as`. This is particularly important because
+ * `type` and `as` could each actually be plain identifiers rather than
+ * keywords.
+ */
+export function tsParseImportSpecifier(): void {
+  parseIdentifier();
+  if (match(tt.comma) || match(tt.braceR)) {
+    // import {foo}
+    state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ImportDeclaration;
+    return;
+  }
+  parseIdentifier();
+  if (match(tt.comma) || match(tt.braceR)) {
+    // import {type foo}
+    state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ImportDeclaration;
+    state.tokens[state.tokens.length - 2].isType = true;
+    state.tokens[state.tokens.length - 1].isType = true;
+    return;
+  }
+  parseIdentifier();
+  if (match(tt.comma) || match(tt.braceR)) {
+    // import {foo as bar}
+    state.tokens[state.tokens.length - 3].identifierRole = IdentifierRole.ImportAccess;
+    state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ImportDeclaration;
+    return;
+  }
+  parseIdentifier();
+  // import {type foo as bar}
+  state.tokens[state.tokens.length - 3].identifierRole = IdentifierRole.ImportAccess;
+  state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ImportDeclaration;
+  state.tokens[state.tokens.length - 4].isType = true;
+  state.tokens[state.tokens.length - 3].isType = true;
+  state.tokens[state.tokens.length - 2].isType = true;
+  state.tokens[state.tokens.length - 1].isType = true;
+}
+
+/**
+ * Just like named import specifiers, export specifiers can have from 1 to 4
+ * tokens, inclusive, and the number of tokens determines the role of each token.
+ */
+export function tsParseExportSpecifier(): void {
+  parseIdentifier();
+  if (match(tt.comma) || match(tt.braceR)) {
+    // export {foo}
+    state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ExportAccess;
+    return;
+  }
+  parseIdentifier();
+  if (match(tt.comma) || match(tt.braceR)) {
+    // export {type foo}
+    state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ExportAccess;
+    state.tokens[state.tokens.length - 2].isType = true;
+    state.tokens[state.tokens.length - 1].isType = true;
+    return;
+  }
+  parseIdentifier();
+  if (match(tt.comma) || match(tt.braceR)) {
+    // export {foo as bar}
+    state.tokens[state.tokens.length - 3].identifierRole = IdentifierRole.ExportAccess;
+    return;
+  }
+  parseIdentifier();
+  // export {type foo as bar}
+  state.tokens[state.tokens.length - 3].identifierRole = IdentifierRole.ExportAccess;
+  state.tokens[state.tokens.length - 4].isType = true;
+  state.tokens[state.tokens.length - 3].isType = true;
+  state.tokens[state.tokens.length - 2].isType = true;
+  state.tokens[state.tokens.length - 1].isType = true;
+}
+
 export function tsTryParseExportDefaultExpression(): boolean {
   if (isContextual(ContextualKeyword._abstract) && lookaheadType() === tt._class) {
     state.type = tt._abstract;
diff --git a/src/parser/traverser/statement.ts b/src/parser/traverser/statement.ts
index 929b554..c41479a 100644
--- a/src/parser/traverser/statement.ts
+++ b/src/parser/traverser/statement.ts
@@ -23,8 +23,10 @@ import {
   tsAfterParseVarHead,
   tsIsDeclarationStart,
   tsParseExportDeclaration,
+  tsParseExportSpecifier,
   tsParseIdentifierStatement,
   tsParseImportEqualsDeclaration,
+  tsParseImportSpecifier,
   tsParseMaybeDecoratorArguments,
   tsParseModifiers,
   tsStartParseFunctionParams,
@@ -1059,12 +1061,19 @@ export function parseExportSpecifiers(): void {
         break;
       }
     }
+    parseExportSpecifier();
+  }
+}
 
+function parseExportSpecifier(): void {
+  if (isTypeScriptEnabled) {
+    tsParseExportSpecifier();
+    return;
+  }
+  parseIdentifier();
+  state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ExportAccess;
+  if (eatContextual(ContextualKeyword._as)) {
     parseIdentifier();
-    state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.ExportAccess;
-    if (eatContextual(ContextualKeyword._as)) {
-      parseIdentifier();
-    }
   }
 }
 
@@ -1164,6 +1173,10 @@ function parseImportSpecifiers(): void {
 }
 
 function parseImportSpecifier(): void {
+  if (isTypeScriptEnabled) {
+    tsParseImportSpecifier();
+    return;
+  }
   if (isFlowEnabled) {
     flowParseImportSpecifier();
     return;
diff --git a/src/transformers/CJSImportTransformer.ts b/src/transformers/CJSImportTransformer.ts
index b10466a..aa05d56 100644
--- a/src/transformers/CJSImportTransformer.ts
+++ b/src/transformers/CJSImportTransformer.ts
@@ -9,6 +9,7 @@ import getDeclarationInfo, {
   DeclarationInfo,
   EMPTY_DECLARATION_INFO,
 } from "../util/getDeclarationInfo";
+import getImportExportSpecifierInfo from "../util/getImportExportSpecifierInfo";
 import shouldElideDefaultExport from "../util/shouldElideDefaultExport";
 import type ReactHotLoaderTransformer from "./ReactHotLoaderTransformer";
 import type RootTransformer from "./RootTransformer";
@@ -742,17 +743,13 @@ export default class CJSImportTransformer extends Transformer {
         break;
       }
 
-      const localName = this.tokens.identifierName();
-      let exportedName;
-      this.tokens.removeToken();
-      if (this.tokens.matchesContextual(ContextualKeyword._as)) {
-        this.tokens.removeToken();
-        exportedName = this.tokens.identifierName();
+      const specifierInfo = getImportExportSpecifierInfo(this.tokens);
+      while (this.tokens.currentIndex() < specifierInfo.endIndex) {
         this.tokens.removeToken();
-      } else {
-        exportedName = localName;
       }
-      if (!this.shouldElideExportedIdentifier(localName)) {
+      if (!specifierInfo.isType && !this.shouldElideExportedIdentifier(specifierInfo.leftName)) {
+        const localName = specifierInfo.leftName;
+        const exportedName = specifierInfo.rightName;
         const newLocalName = this.importProcessor.getIdentifierReplacement(localName);
         exportStatements.push(`exports.${exportedName} = ${newLocalName || localName};`);
       }
diff --git a/src/transformers/ESMImportTransformer.ts b/src/transformers/ESMImportTransformer.ts
index 3043162..87f9123 100644
--- a/src/transformers/ESMImportTransformer.ts
+++ b/src/transformers/ESMImportTransformer.ts
@@ -8,6 +8,7 @@ import getDeclarationInfo, {
   DeclarationInfo,
   EMPTY_DECLARATION_INFO,
 } from "../util/getDeclarationInfo";
+import getImportExportSpecifierInfo from "../util/getImportExportSpecifierInfo";
 import {getNonTypeIdentifiers} from "../util/getNonTypeIdentifiers";
 import shouldElideDefaultExport from "../util/shouldElideDefaultExport";
 import type ReactHotLoaderTransformer from "./ReactHotLoaderTransformer";
@@ -190,68 +191,22 @@ export default class ESMImportTransformer extends Transformer {
     } else if (this.tokens.matches1(tt.braceL)) {
       this.tokens.copyToken();
       while (!this.tokens.matches1(tt.braceR)) {
-        if (
-          this.tokens.matches3(tt.name, tt.name, tt.comma) ||
-          this.tokens.matches3(tt.name, tt.name, tt.braceR)
-        ) {
-          // type foo
-          this.tokens.removeToken();
-          this.tokens.removeToken();
-          if (this.tokens.matches1(tt.comma)) {
+        const specifierInfo = getImportExportSpecifierInfo(this.tokens);
+        if (specifierInfo.isType || this.isTypeName(specifierInfo.rightName)) {
+          while (this.tokens.currentIndex() < specifierInfo.endIndex) {
             this.tokens.removeToken();
           }
-        } else if (
-          this.tokens.matches5(tt.name, tt.name, tt.name, tt.name, tt.comma) ||
-          this.tokens.matches5(tt.name, tt.name, tt.name, tt.name, tt.braceR)
-        ) {
-          // type foo as bar
-          this.tokens.removeToken();
-          this.tokens.removeToken();
-          this.tokens.removeToken();
-          this.tokens.removeToken();
           if (this.tokens.matches1(tt.comma)) {
             this.tokens.removeToken();
           }
-        } else if (
-          this.tokens.matches2(tt.name, tt.comma) ||
-          this.tokens.matches2(tt.name, tt.braceR)
-        ) {
-          // foo
-          if (this.isTypeName(this.tokens.identifierName())) {
-            this.tokens.removeToken();
-            if (this.tokens.matches1(tt.comma)) {
-              this.tokens.removeToken();
-            }
-          } else {
-            foundNonTypeImport = true;
+        } else {
+          foundNonTypeImport = true;
+          while (this.tokens.currentIndex() < specifierInfo.endIndex) {
             this.tokens.copyToken();
-            if (this.tokens.matches1(tt.comma)) {
-              this.tokens.copyToken();
-            }
           }
-        } else if (
-          this.tokens.matches4(tt.name, tt.name, tt.name, tt.comma) ||
-          this.tokens.matches4(tt.name, tt.name, tt.name, tt.braceR)
-        ) {
-          // foo as bar
-          if (this.isTypeName(this.tokens.identifierNameAtIndex(this.tokens.currentIndex() + 2))) {
-            this.tokens.removeToken();
-            this.tokens.removeToken();
-            this.tokens.removeToken();
-            if (this.tokens.matches1(tt.comma)) {
-              this.tokens.removeToken();
-            }
-          } else {
-            foundNonTypeImport = true;
-            this.tokens.copyToken();
-            this.tokens.copyToken();
+          if (this.tokens.matches1(tt.comma)) {
             this.tokens.copyToken();
-            if (this.tokens.matches1(tt.comma)) {
-              this.tokens.copyToken();
-            }
           }
-        } else {
-          throw new Error("Unexpected import form.");
         }
       }
       this.tokens.copyExpectedToken(tt.braceR);
@@ -313,26 +268,18 @@ export default class ESMImportTransformer extends Transformer {
     this.tokens.copyExpectedToken(tt.braceL);
 
     while (!this.tokens.matches1(tt.braceR)) {
-      if (!this.tokens.matches1(tt.name)) {
-        throw new Error("Expected identifier at the start of named export.");
-      }
-      if (this.shouldElideExportedName(this.tokens.identifierName())) {
-        while (
-          !this.tokens.matches1(tt.comma) &&
-          !this.tokens.matches1(tt.braceR) &&
-          !this.tokens.isAtEnd()
-        ) {
+      const specifierInfo = getImportExportSpecifierInfo(this.tokens);
+      if (specifierInfo.isType || this.shouldElideExportedName(specifierInfo.leftName)) {
+        // Type export, so remove all tokens, including any comma.
+        while (this.tokens.currentIndex() < specifierInfo.endIndex) {
           this.tokens.removeToken();
         }
         if (this.tokens.matches1(tt.comma)) {
           this.tokens.removeToken();
         }
       } else {
-        while (
-          !this.tokens.matches1(tt.comma) &&
-          !this.tokens.matches1(tt.braceR) &&
-          !this.tokens.isAtEnd()
-        ) {
+        // Non-type export, so copy all tokens, including any comma.
+        while (this.tokens.currentIndex() < specifierInfo.endIndex) {
           this.tokens.copyToken();
         }
         if (this.tokens.matches1(tt.comma)) {
@@ -346,7 +293,7 @@ export default class ESMImportTransformer extends Transformer {
 
   /**
    * ESM elides all imports with the rule that we only elide if we see that it's
-   * a type and never see it as a value. This is in contract to CJS, which
+   * a type and never see it as a value. This is in contrast to CJS, which
    * elides imports that are completely unknown.
    */
   private shouldElideExportedName(name: string): boolean {
diff --git a/src/util/getImportExportSpecifierInfo.ts b/src/util/getImportExportSpecifierInfo.ts
new file mode 100644
index 0000000..4aa7743
--- /dev/null
+++ b/src/util/getImportExportSpecifierInfo.ts
@@ -0,0 +1,92 @@
+import {TokenType as tt} from "../parser/tokenizer/types";
+import type TokenProcessor from "../TokenProcessor";
+
+export type ImportExportSpecifierInfo =
+  | {
+      isType: false;
+      leftName: string;
+      rightName: string;
+      endIndex: number;
+    }
+  | {
+      isType: true;
+      leftName: null;
+      rightName: null;
+      endIndex: number;
+    };
+
+/**
+ * Determine information about this named import or named export specifier.
+ *
+ * This syntax is the `a` from statements like these:
+ * import {A} from "./foo";
+ * export {A};
+ * export {A} from "./foo";
+ *
+ * As it turns out, we can exactly characterize the syntax meaning by simply
+ * counting the number of tokens, which can be from 1 to 4:
+ * {A}
+ * {type A}
+ * {A as B}
+ * {type A as B}
+ *
+ * In the type case, we never actually need the names in practice, so don't get
+ * them.
+ *
+ * TODO: There's some redundancy with the type detection here and the isType
+ * flag that's already present on tokens in TS mode. This function could
+ * potentially be simplified and/or pushed to the call sites to avoid the object
+ * allocation.
+ */
+export default function getImportExportSpecifierInfo(
+  tokens: TokenProcessor,
+  index: number = tokens.currentIndex(),
+): ImportExportSpecifierInfo {
+  let endIndex = index + 1;
+  if (isSpecifierEnd(tokens, endIndex)) {
+    // import {A}
+    const name = tokens.identifierNameAtIndex(index);
+    return {
+      isType: false,
+      leftName: name,
+      rightName: name,
+      endIndex,
+    };
+  }
+  endIndex++;
+  if (isSpecifierEnd(tokens, endIndex)) {
+    // import {type A}
+    return {
+      isType: true,
+      leftName: null,
+      rightName: null,
+      endIndex,
+    };
+  }
+  endIndex++;
+  if (isSpecifierEnd(tokens, endIndex)) {
+    // import {A as B}
+    return {
+      isType: false,
+      leftName: tokens.identifierNameAtIndex(index),
+      rightName: tokens.identifierNameAtIndex(index + 2),
+      endIndex,
+    };
+  }
+  endIndex++;
+  if (isSpecifierEnd(tokens, endIndex)) {
+    // import {type A as B}
+    return {
+      isType: true,
+      leftName: null,
+      rightName: null,
+      endIndex,
+    };
+  }
+  throw new Error(`Unexpected import/export specifier at ${index}`);
+}
+
+function isSpecifierEnd(tokens: TokenProcessor, index: number): boolean {
+  const token = tokens.tokens[index];
+  return token.type === tt.braceR || token.type === tt.comma;
+}
diff --git a/src/util/getTSImportedNames.ts b/src/util/getTSImportedNames.ts
index f72dbe6..beb67ad 100644
--- a/src/util/getTSImportedNames.ts
+++ b/src/util/getTSImportedNames.ts
@@ -1,6 +1,6 @@
-import {ContextualKeyword} from "../parser/tokenizer/keywords";
 import {TokenType as tt} from "../parser/tokenizer/types";
 import type TokenProcessor from "../TokenProcessor";
+import getImportExportSpecifierInfo from "./getImportExportSpecifierInfo";
 
 /**
  * Special case code to scan for imported names in ESM TypeScript. We need to do this so we can
@@ -65,16 +65,12 @@ function collectNamesForNamedImport(
       return;
     }
 
-    // We care about the local name, which might be the first token, or if there's an "as", is the
-    // one after that.
-    let name = tokens.identifierNameAtIndex(index);
-    index++;
-    if (tokens.matchesContextualAtIndex(index, ContextualKeyword._as)) {
-      index++;
-      name = tokens.identifierNameAtIndex(index);
-      index++;
+    const specifierInfo = getImportExportSpecifierInfo(tokens, index);
+    index = specifierInfo.endIndex;
+    if (!specifierInfo.isType) {
+      importedNames.add(specifierInfo.rightName);
     }
-    importedNames.add(name);
+
     if (tokens.matches2AtIndex(index, tt.comma, tt.braceR)) {
       return;
     } else if (tokens.matches1AtIndex(index, tt.braceR)) {


Test patch:
diff --git a/test/typescript-test.ts b/test/typescript-test.ts
index e3d9ada..e5e5de3 100644
--- a/test/typescript-test.ts
+++ b/test/typescript-test.ts
@@ -1596,17 +1596,20 @@ describe("typescript transform", () => {
       `
       type T = number;
       type U = number;
+      const w = 3;
       export {T, U as w};
     `,
       {
         expectedCJSResult: `"use strict";${ESMODULE_PREFIX}
       
 
+      const w = 3;
       
     `,
         expectedESMResult: `
       
 
+      const w = 3;
       export {};
     `,
       },
@@ -2650,6 +2653,136 @@ describe("typescript transform", () => {
     );
   });
 
+  it("handles type-only import specifiers", () => {
+    assertTypeScriptESMResult(
+      `
+      import {type Foo, Bar} from "A";
+      function f(): Foo {}
+      console.log(Bar);
+    `,
+      `
+      import { Bar} from "A";
+      function f() {}
+      console.log(Bar);
+    `,
+    );
+  });
+
+  it("elides TS imports consisting of only named type imports", () => {
+    assertTypeScriptESMResult(
+      `
+      import {type Foo, type Bar} from "A";
+      console.log(Foo);
+    `,
+      `
+
+      console.log(Foo);
+    `,
+    );
+  });
+
+  it("elides TS type exports in ESM mode", () => {
+    assertTypeScriptESMResult(
+      `
+      class A {}
+      class C {}
+      class B {}
+      export {type A, B, type C as D};
+    `,
+      `
+      class A {}
+      class C {}
+      class B {}
+      export { B,};
+    `,
+    );
+  });
+
+  it("elides TS type exports in CJS mode", () => {
+    assertTypeScriptResult(
+      `
+      class A {}
+      class C {}
+      class B {}
+      export {type A, B, type C as D};
+    `,
+      `"use strict";${ESMODULE_PREFIX}
+      class A {}
+      class C {}
+      class B {}
+      exports.B = B;
+    `,
+    );
+  });
+
+  it("elides ESM type imports used in a value context", () => {
+    assertTypeScriptESMResult(
+      `
+      import {type Foo, type Bar as Baz} from "A";
+      console.log(Foo);
+      console.log(Bar);
+      console.log(Baz);
+    `,
+      `
+
+      console.log(Foo);
+      console.log(Bar);
+      console.log(Baz);
+    `,
+    );
+  });
+
+  it("preserves CJS value usage of names defined via a type import", () => {
+    assertTypeScriptResult(
+      `
+      import {type Foo, type Bar as Baz} from "A";
+      console.log(Foo);
+      console.log(Bar);
+      console.log(Baz);
+    `,
+      `"use strict";
+      
+      console.log(Foo);
+      console.log(Bar);
+      console.log(Baz);
+    `,
+    );
+  });
+
+  it("removes re-exported types in ESM mode", () => {
+    assertTypeScriptESMResult(
+      `
+      import {type T, type U, V} from './foo';
+      export {T};
+      export {U as foo};
+      export {V};
+    `,
+      `
+      import { V} from './foo';
+      export {};
+      export {};
+      export {V};
+    `,
+    );
+  });
+
+  it("removes re-exported types in CJS mode", () => {
+    assertTypeScriptResult(
+      `
+      import {type T, type U, V} from './foo';
+      export {T};
+      export {U as foo};
+      export {V};
+    `,
+      `"use strict";${ESMODULE_PREFIX}
+      var _foo = require('./foo');
+      
+      
+      exports.V = _foo.V;
+    `,
+    );
+  });
+
   it("allows static index signatures", () => {
     assertTypeScriptESMResult(
       `