Repository: alangpierce/sucrase (ts)

Issue:
Add option to disable TS import/export elision
Follow-up from #783

## Background

TypeScript 5.0 released a new option `--verbatimModuleSyntax`, and this issue tracks the implementation of some kind of equivalent option. The new TS option is described here: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#verbatimmodulesyntax .

The option actually has two behaviors:
1. Disable all automatic import/export elision (removal of type-only or unused imports and exports), and instead require explicit `type` syntax.
2. Disallow all ESM -> CJS conversion.

I'm glad to see behavior 1 as a replacement for the now-deprecated `--importsNotUsedAsValues` and `--preserveValueImports`, since it has much simpler behavior and fully avoids the need for Sucrase to track variables and scopes.

I have mixed feelings about behavior 2, and I'm not sure it makes sense for Sucrase to bundle the two behaviors together the way TS does. The TS behavior seems to be entirely error-checking, not affecting emit, so one interpretation is that Sucrase should simply ignore behavior 2 since Sucrase leaves error checking to other tools. Another interpretation is that Sucrase should disallow setting this flag and the `imports` transform at the same time, or have the flag automatically disable the `imports` transform, but both of those seem a bit strange and arbitrary as opposed to the two options being independently configurable (which should be easy to implement). The codebase I work with at work is an example of a situation where ESM -> CJS transformation is still needed and it would be nice to avoid import/export elision for better understandability.

That said, I am happy to see a potential future path where `.cts` files no longer attempt to simulate ESM in CJS, which would mean that Sucrase would be able to eventually fully sunset the `imports` transform even if CJS has some occasional legacy uses.

## Details

The plan for this task is to create a new flag that disables all import/export elision from the `typescript` transform. This was actually previously proposed with a PR in https://github.com/alangpierce/sucrase/pull/615 , so that will be a good starting point. All normal ESM syntax will need a `type` keyword to remove the whole statement or parts of it. When used with the `imports` transform, the `type` syntax will determine whether to remove the `require` call.

Since the behavior is a little different from TS `verbatimModuleSyntax` (since there's no behavior 2 mentioned above), it's probably best to give it a different name. The decision on name can come later, but some options that come to mind:
* `keepUnusedImports` (used in the linked PR)
* `preserveTSImports`
* `disableImportExportElision`

Pull request description:
Add keepUnusedImports option
Supersedes #615
Fixes #791

This PR builds off of #615 with a number of additional tests and cases. High-level, it introduces a new option `keepUnusedImports` that disables all automatic import and export elision done by TypeScript, somewhat analogous to the `verbatimModuleSyntax` TS option. For the most part, this is just a matter of flagging off the name tracking and elision code
for both imports and exports in the various places that they happen.

When this mode is enabled, some of the preprocessing steps can be skipped, and scope handling can be fully skipped with
the imports transform also disabled. Performance numbers measured by `yarn benchmark jest-dev`:
* With `imports` and `typescript` transforms: 855k -> 910k lines per second (6% faster)
* With only `typescript` transform: 935k -> 1110k lines per second (19% faster)

Some additional details:
* While this feature is intended to be used with TypeScript, I decided to also implement the behavior for Flow as well. Flow's implementation in Babel will remove an import statement if all individual names are removed, but `flow-remove-types` won't do this, so Sucrase `keepUnusedImports` matches the behavior of `flow-remove-types`.
* Babel doesn't currently implement this option, so for the playground I mapped it to `onlyRemoveTypeImports`, which
  seems to have the closest behavior.

Patch:
diff --git a/README.md b/README.md
index 90e13dd..c6f4ef4 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,9 @@ transforms are available:
   annotations and handling features like enums. Does not check types. Sucrase
   transforms each file independently, so you should enable the `isolatedModules`
   TypeScript flag so that the typechecker will disallow the few features like
-  `const enum`s that need cross-file compilation.
+  `const enum`s that need cross-file compilation. The Sucrase option `keepUnusedImports`
+  can be used to disable all automatic removal of imports and exports, analogous to TS
+  `verbatimModuleSyntax`.
 * **flow**:  Removes Flow type annotations. Does not check types.
 * **imports**: Transforms ES Modules (`import`/`export`) to CommonJS
   (`require`/`module.exports`) using the same approach as Babel and TypeScript
diff --git a/src/CJSImportProcessor.ts b/src/CJSImportProcessor.ts
index d3ca41b..14f62ef 100644
--- a/src/CJSImportProcessor.ts
+++ b/src/CJSImportProcessor.ts
@@ -43,6 +43,7 @@ export default class CJSImportProcessor {
     readonly enableLegacyTypeScriptModuleInterop: boolean,
     readonly options: Options,
     readonly isTypeScriptTransformEnabled: boolean,
+    readonly keepUnusedImports: boolean,
     readonly helperManager: HelperManager,
   ) {}
 
@@ -65,8 +66,8 @@ export default class CJSImportProcessor {
   }
 
   /**
-   * In TypeScript, import statements that only import types should be removed. This does not count
-   * bare imports.
+   * In TypeScript, import statements that only import types should be removed.
+   * This includes `import {} from 'foo';`, but not `import 'foo';`.
    */
   pruneTypeOnlyImports(): void {
     this.nonTypeIdentifiers = getNonTypeIdentifiers(this.tokens, this.options);
@@ -84,14 +85,18 @@ export default class CJSImportProcessor {
         ...importInfo.wildcardNames,
         ...importInfo.namedImports.map(({localName}) => localName),
       ];
-      if (names.every((name) => this.isTypeName(name))) {
+      if (names.every((name) => this.shouldAutomaticallyElideImportedName(name))) {
         this.importsToReplace.set(path, "");
       }
     }
   }
 
-  isTypeName(name: string): boolean {
-    return this.isTypeScriptTransformEnabled && !this.nonTypeIdentifiers.has(name);
+  shouldAutomaticallyElideImportedName(name: string): boolean {
+    return (
+      this.isTypeScriptTransformEnabled &&
+      !this.keepUnusedImports &&
+      !this.nonTypeIdentifiers.has(name)
+    );
   }
 
   private generateImportReplacements(): void {
diff --git a/src/Options-gen-types.ts b/src/Options-gen-types.ts
index 9a407fd..85ade2d 100644
--- a/src/Options-gen-types.ts
+++ b/src/Options-gen-types.ts
@@ -25,6 +25,7 @@ export const Options = t.iface([], {
   jsxImportSource: t.opt("string"),
   jsxPragma: t.opt("string"),
   jsxFragmentPragma: t.opt("string"),
+  keepUnusedImports: t.opt("boolean"),
   preserveDynamicImport: t.opt("boolean"),
   injectCreateRequireForImportRequire: t.opt("boolean"),
   enableLegacyTypeScriptModuleInterop: t.opt("boolean"),
diff --git a/src/Options.ts b/src/Options.ts
index f855047..d131c0b 100644
--- a/src/Options.ts
+++ b/src/Options.ts
@@ -55,6 +55,12 @@ export interface Options {
    * compiling JSX with the classic runtime.
    */
   jsxFragmentPragma?: string;
+  /**
+   * If specified, disable automatic removal of type-only import and export
+   * statements and names. Only statements and names that explicitly use the
+   * `type` keyword are removed.
+   */
+  keepUnusedImports?: boolean;
   /**
    * If specified, the imports transform does not attempt to change dynamic
    * import() expressions into require() calls.
diff --git a/src/index.ts b/src/index.ts
index 7ca1595..607aa64 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -115,16 +115,18 @@ function getSucraseContext(code: string, options: Options): SucraseContext {
       enableLegacyTypeScriptModuleInterop,
       options,
       options.transforms.includes("typescript"),
+      Boolean(options.keepUnusedImports),
       helperManager,
     );
     importProcessor.preprocessTokens();
     // We need to mark shadowed globals after processing imports so we know that the globals are,
     // but before type-only import pruning, since that relies on shadowing information.
     identifyShadowedGlobals(tokenProcessor, scopes, importProcessor.getGlobalNames());
-    if (options.transforms.includes("typescript")) {
+    if (options.transforms.includes("typescript") && !options.keepUnusedImports) {
       importProcessor.pruneTypeOnlyImports();
     }
-  } else if (options.transforms.includes("typescript")) {
+  } else if (options.transforms.includes("typescript") && !options.keepUnusedImports) {
+    // Shadowed global detection is needed for TS implicit elision of imported names.
     identifyShadowedGlobals(tokenProcessor, scopes, getTSImportedNames(tokenProcessor));
   }
   return {tokenProcessor, scopes, nameManager, importProcessor, helperManager};
diff --git a/src/transformers/CJSImportTransformer.ts b/src/transformers/CJSImportTransformer.ts
index 0bab773..fa367ee 100644
--- a/src/transformers/CJSImportTransformer.ts
+++ b/src/transformers/CJSImportTransformer.ts
@@ -39,6 +39,7 @@ export default class CJSImportTransformer extends Transformer {
     readonly isTypeScriptTransformEnabled: boolean,
     readonly isFlowTransformEnabled: boolean,
     readonly preserveDynamicImport: boolean,
+    readonly keepUnusedImports: boolean,
   ) {
     super();
     this.declarationInfo = isTypeScriptTransformEnabled
@@ -101,7 +102,7 @@ export default class CJSImportTransformer extends Transformer {
 
   private processImportEquals(): boolean {
     const importName = this.tokens.identifierNameAtIndex(this.tokens.currentIndex() + 1);
-    if (this.importProcessor.isTypeName(importName)) {
+    if (this.importProcessor.shouldAutomaticallyElideImportedName(importName)) {
       // If this name is only used as a type, elide the whole import.
       elideImportEquals(this.tokens);
     } else {
@@ -222,6 +223,9 @@ export default class CJSImportTransformer extends Transformer {
       }
       this.tokens.removeToken();
     }
+    if (this.keepUnusedImports) {
+      return false;
+    }
     if (this.isTypeScriptTransformEnabled) {
       return !foundNonTypeImport;
     } else if (this.isFlowTransformEnabled) {
@@ -546,7 +550,12 @@ export default class CJSImportTransformer extends Transformer {
       this.tokens.appendCode(` exports.default = ${name};`);
       // After this point, this is a plain "export default E" statement.
     } else if (
-      shouldElideDefaultExport(this.isTypeScriptTransformEnabled, this.tokens, this.declarationInfo)
+      shouldElideDefaultExport(
+        this.isTypeScriptTransformEnabled,
+        this.keepUnusedImports,
+        this.tokens,
+        this.declarationInfo,
+      )
     ) {
       // If the exported value is just an identifier and should be elided by TypeScript
       // rules, then remove it entirely. It will always have the form `export default e`,
@@ -898,6 +907,10 @@ export default class CJSImportTransformer extends Transformer {
   }
 
   private shouldElideExportedIdentifier(name: string): boolean {
-    return this.isTypeScriptTransformEnabled && !this.declarationInfo.valueDeclarations.has(name);
+    return (
+      this.isTypeScriptTransformEnabled &&
+      !this.keepUnusedImports &&
+      !this.declarationInfo.valueDeclarations.has(name)
+    );
   }
 }
diff --git a/src/transformers/ESMImportTransformer.ts b/src/transformers/ESMImportTransformer.ts
index d40dd4c..eb550cf 100644
--- a/src/transformers/ESMImportTransformer.ts
+++ b/src/transformers/ESMImportTransformer.ts
@@ -33,15 +33,18 @@ export default class ESMImportTransformer extends Transformer {
     readonly reactHotLoaderTransformer: ReactHotLoaderTransformer | null,
     readonly isTypeScriptTransformEnabled: boolean,
     readonly isFlowTransformEnabled: boolean,
+    readonly keepUnusedImports: boolean,
     options: Options,
   ) {
     super();
-    this.nonTypeIdentifiers = isTypeScriptTransformEnabled
-      ? getNonTypeIdentifiers(tokens, options)
-      : new Set();
-    this.declarationInfo = isTypeScriptTransformEnabled
-      ? getDeclarationInfo(tokens)
-      : EMPTY_DECLARATION_INFO;
+    this.nonTypeIdentifiers =
+      isTypeScriptTransformEnabled && !keepUnusedImports
+        ? getNonTypeIdentifiers(tokens, options)
+        : new Set();
+    this.declarationInfo =
+      isTypeScriptTransformEnabled && !keepUnusedImports
+        ? getDeclarationInfo(tokens)
+        : EMPTY_DECLARATION_INFO;
     this.injectCreateRequireForImportRequire = Boolean(options.injectCreateRequireForImportRequire);
   }
 
@@ -277,6 +280,9 @@ export default class ESMImportTransformer extends Transformer {
       this.tokens.copyExpectedToken(tt.braceR);
     }
 
+    if (this.keepUnusedImports) {
+      return false;
+    }
     if (this.isTypeScriptTransformEnabled) {
       return !foundNonTypeImport;
     } else if (this.isFlowTransformEnabled) {
@@ -288,12 +294,21 @@ export default class ESMImportTransformer extends Transformer {
   }
 
   private shouldAutomaticallyElideImportedName(name: string): boolean {
-    return this.isTypeScriptTransformEnabled && !this.nonTypeIdentifiers.has(name);
+    return (
+      this.isTypeScriptTransformEnabled &&
+      !this.keepUnusedImports &&
+      !this.nonTypeIdentifiers.has(name)
+    );
   }
 
   private processExportDefault(): boolean {
     if (
-      shouldElideDefaultExport(this.isTypeScriptTransformEnabled, this.tokens, this.declarationInfo)
+      shouldElideDefaultExport(
+        this.isTypeScriptTransformEnabled,
+        this.keepUnusedImports,
+        this.tokens,
+        this.declarationInfo,
+      )
     ) {
       // If the exported value is just an identifier and should be elided by TypeScript
       // rules, then remove it entirely. It will always have the form `export default e`,
@@ -373,7 +388,7 @@ export default class ESMImportTransformer extends Transformer {
     }
     this.tokens.copyExpectedToken(tt.braceR);
 
-    if (isReExport && !foundNonTypeExport) {
+    if (!this.keepUnusedImports && isReExport && !foundNonTypeExport) {
       // This is a type-only re-export, so skip evaluating the other module. Technically this
       // leaves the statement as `export {}`, but that's ok since that's a no-op.
       this.tokens.removeToken();
@@ -392,6 +407,7 @@ export default class ESMImportTransformer extends Transformer {
   private shouldElideExportedName(name: string): boolean {
     return (
       this.isTypeScriptTransformEnabled &&
+      !this.keepUnusedImports &&
       this.declarationInfo.typeDeclarations.has(name) &&
       !this.declarationInfo.valueDeclarations.has(name)
     );
diff --git a/src/transformers/RootTransformer.ts b/src/transformers/RootTransformer.ts
index 40ec67f..b0663b6 100644
--- a/src/transformers/RootTransformer.ts
+++ b/src/transformers/RootTransformer.ts
@@ -97,6 +97,7 @@ export default class RootTransformer {
           transforms.includes("typescript"),
           transforms.includes("flow"),
           Boolean(options.preserveDynamicImport),
+          Boolean(options.keepUnusedImports),
         ),
       );
     } else {
@@ -108,6 +109,7 @@ export default class RootTransformer {
           reactHotLoaderTransformer,
           transforms.includes("typescript"),
           transforms.includes("flow"),
+          Boolean(options.keepUnusedImports),
           options,
         ),
       );
diff --git a/src/util/shouldElideDefaultExport.ts b/src/util/shouldElideDefaultExport.ts
index 7d969fa..b1a9a03 100644
--- a/src/util/shouldElideDefaultExport.ts
+++ b/src/util/shouldElideDefaultExport.ts
@@ -7,10 +7,11 @@ import type {DeclarationInfo} from "./getDeclarationInfo";
  */
 export default function shouldElideDefaultExport(
   isTypeScriptTransformEnabled: boolean,
+  keepUnusedImports: boolean,
   tokens: TokenProcessor,
   declarationInfo: DeclarationInfo,
 ): boolean {
-  if (!isTypeScriptTransformEnabled) {
+  if (!isTypeScriptTransformEnabled || keepUnusedImports) {
     return false;
   }
   const exportToken = tokens.currentToken();
diff --git a/ts-node-plugin/index.js b/ts-node-plugin/index.js
index d072c67..b523fb3 100644
--- a/ts-node-plugin/index.js
+++ b/ts-node-plugin/index.js
@@ -21,14 +21,20 @@ const JsxEmitReactJSXDev = 5;
  * JS syntax downleveling (at least not in a way that is useful for Node).
  *
  * One notable caveat is that importsNotUsedAsValues and preserveValueImports
- * are ignored right now, and Sucrase uses TypeScript's default behavior of
- * eliding imports only used as types. This usually makes no difference when
- * running the code, so for now we ignore these options without a warning.
+ * are ignored right now, since they are deprecated and don't have exact Sucrase
+ * equivalents. To preserve imports and exports, use verbatimModuleSyntax.
  */
 function create(createOptions) {
   const {nodeModuleEmitKind} = createOptions;
-  const {module, jsx, jsxFactory, jsxFragmentFactory, jsxImportSource, esModuleInterop} =
-    createOptions.service.config.options;
+  const {
+    module,
+    jsx,
+    jsxFactory,
+    jsxFragmentFactory,
+    jsxImportSource,
+    esModuleInterop,
+    verbatimModuleSyntax,
+  } = createOptions.service.config.options;
 
   return {
     transpile(input, transpileOptions) {
@@ -59,6 +65,7 @@ function create(createOptions) {
         jsxImportSource,
         jsxPragma: jsxFactory,
         jsxFragmentPragma: jsxFragmentFactory,
+        keepUnusedImports: verbatimModuleSyntax,
         preserveDynamicImport: nodeModuleEmitKind === "nodecjs",
         injectCreateRequireForImportRequire: nodeModuleEmitKind === "nodeesm",
         enableLegacyTypeScriptModuleInterop: !esModuleInterop,
diff --git a/website/src/Constants.ts b/website/src/Constants.ts
index 58ae205..d9a13b3 100644
--- a/website/src/Constants.ts
+++ b/website/src/Constants.ts
@@ -59,6 +59,7 @@ export const DEFAULT_OPTIONS: HydratedOptions = {
   jsxImportSource: "react",
   jsxPragma: "React.createElement",
   jsxFragmentPragma: "React.Fragment",
+  keepUnusedImports: false,
   preserveDynamicImport: false,
   injectCreateRequireForImportRequire: false,
   enableLegacyTypeScriptModuleInterop: false,
diff --git a/website/src/SucraseOptionsBox.tsx b/website/src/SucraseOptionsBox.tsx
index 18389ae..ec71dca 100644
--- a/website/src/SucraseOptionsBox.tsx
+++ b/website/src/SucraseOptionsBox.tsx
@@ -33,6 +33,7 @@ interface BooleanOptionProps {
     | "enableLegacyBabel5ModuleInterop"
     | "production"
     | "disableESTransforms"
+    | "keepUnusedImports"
     | "preserveDynamicImport"
     | "injectCreateRequireForImportRequire";
   options: HydratedOptions;
@@ -200,6 +201,14 @@ export default function SucraseOptionsBox({
               )}
             </div>
             <div className={css(styles.secondaryOptionColumn)}>
+              {(options.transforms.includes("typescript") ||
+                options.transforms.includes("flow")) && (
+                <BooleanOption
+                  optionName="keepUnusedImports"
+                  options={options}
+                  onUpdateOptions={onUpdateOptions}
+                />
+              )}
               {options.transforms.includes("imports") && (
                 <>
                   <BooleanOption
diff --git a/website/src/worker/Worker.worker.ts b/website/src/worker/Worker.worker.ts
index f533090..cf8fdd3 100644
--- a/website/src/worker/Worker.worker.ts
+++ b/website/src/worker/Worker.worker.ts
@@ -159,7 +159,10 @@ function runBabel(): {code: string; time: number | null} {
     }
   }
   if (sucraseOptions.transforms.includes("typescript")) {
-    presets.push(["typescript", {allowDeclareFields: true}]);
+    presets.push([
+      "typescript",
+      {allowDeclareFields: true, onlyRemoveTypeImports: sucraseOptions.keepUnusedImports},
+    ]);
   }
   if (sucraseOptions.transforms.includes("flow")) {
     presets.push("flow");
@@ -257,6 +260,7 @@ function runTypeScript(): {code: string; time: number | null} {
           esModuleInterop: !sucraseOptions.enableLegacyTypeScriptModuleInterop,
           jsxFactory: sucraseOptions.jsxPragma,
           jsxFragmentFactory: sucraseOptions.jsxFragmentPragma,
+          verbatimModuleSyntax: sucraseOptions.keepUnusedImports,
         },
       }).outputText,
   );


Test patch:
diff --git a/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/main.ts b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/main.ts
new file mode 100644
index 0000000..4e3406a
--- /dev/null
+++ b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/main.ts
@@ -0,0 +1,6 @@
+// This import should be preserved.
+import A from './set-global-to-3.js'
+
+if (global.testValue !== 3) {
+  throw new Error();
+}
diff --git a/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/package.json b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/package.json
new file mode 100644
index 0000000..3dbc1ca
--- /dev/null
+++ b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/package.json
@@ -0,0 +1,3 @@
+{
+  "type": "module"
+}
diff --git a/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/set-global-to-3.ts b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/set-global-to-3.ts
new file mode 100644
index 0000000..dcfc0c4
--- /dev/null
+++ b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/set-global-to-3.ts
@@ -0,0 +1,2 @@
+global.testValue = 3;
+export default 2;
diff --git a/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/tsconfig.json b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/tsconfig.json
new file mode 100644
index 0000000..3ab05e5
--- /dev/null
+++ b/integration-test/test-cases/ts-node-cases/option-cases/respects-verbatim-module-syntax/tsconfig.json
@@ -0,0 +1,11 @@
+{
+  "compilerOptions": {
+    "module": "NodeNext",
+    "target": "ESNext",
+    "esModuleInterop": true,
+    "verbatimModuleSyntax": true
+  },
+  "ts-node": {
+    "experimentalResolver": true
+  }
+}
diff --git a/test/flow-test.ts b/test/flow-test.ts
index 4175035..ffabde1 100644
--- a/test/flow-test.ts
+++ b/test/flow-test.ts
@@ -21,7 +21,7 @@ function assertFlowESMResult(
 }
 
 describe("transform flow", () => {
-  it("removes `import type` statements", () => {
+  it("removes `import type` statements when targeting CJS", () => {
     assertFlowResult(
       `
       import type {a} from 'b';
@@ -42,6 +42,47 @@ describe("transform flow", () => {
     );
   });
 
+  it("properly prunes flow imported names when when targeting ESM", () => {
+    assertFlowESMResult(
+      `
+      import a, {type n as b, m as c, type d} from './e';
+      import type f from './g';
+      import {type h} from './i';
+      import j, {} from './k';
+    `,
+      `
+      import a, { m as c,} from './e';
+
+
+      import j, {} from './k';
+    `,
+    );
+  });
+
+  it("respects keepUnusedImports when targeting CJS", () => {
+    assertFlowResult(
+      `
+      import {type T} from 'a';
+    `,
+      `"use strict";
+      require('a');
+    `,
+      {keepUnusedImports: true},
+    );
+  });
+
+  it("respects keepUnusedImports when targeting ESM", () => {
+    assertFlowESMResult(
+      `
+      import {type T} from 'a';
+    `,
+      `
+      import {} from 'a';
+    `,
+      {keepUnusedImports: true},
+    );
+  });
+
   it("preserves import {} statements when targeting CJS", () => {
     assertFlowResult(
       `
@@ -270,23 +311,6 @@ describe("transform flow", () => {
     );
   });
 
-  it("properly prunes flow imported names", () => {
-    assertFlowESMResult(
-      `
-      import a, {type n as b, m as c, type d} from './e';
-      import type f from './g';
-      import {type h} from './i';
-      import j, {} from './k';
-    `,
-      `
-      import a, { m as c,} from './e';
-
-
-      import j, {} from './k';
-    `,
-    );
-  });
-
   it("removes @flow directives", () => {
     assertFlowResult(
       `
diff --git a/test/identifyShadowedGlobals-test.ts b/test/identifyShadowedGlobals-test.ts
index a8814e3..223e82b 100644
--- a/test/identifyShadowedGlobals-test.ts
+++ b/test/identifyShadowedGlobals-test.ts
@@ -20,6 +20,7 @@ function assertHasShadowedGlobals(code: string, expected: boolean): void {
       transforms: [],
     },
     false,
+    false,
     helperManager,
   );
   importProcessor.preprocessTokens();
diff --git a/test/typescript-test.ts b/test/typescript-test.ts
index 29ae7c3..d36d242 100644
--- a/test/typescript-test.ts
+++ b/test/typescript-test.ts
@@ -30,10 +30,14 @@ function assertTypeScriptESMResult(
 
 function assertTypeScriptImportResult(
   code: string,
-  {expectedCJSResult, expectedESMResult}: {expectedCJSResult: string; expectedESMResult: string},
+  {
+    expectedCJSResult,
+    expectedESMResult,
+    options = {},
+  }: {expectedCJSResult: string; expectedESMResult: string; options?: Partial<Options>},
 ): void {
-  assertTypeScriptResult(code, expectedCJSResult);
-  assertTypeScriptESMResult(code, expectedESMResult);
+  assertTypeScriptResult(code, expectedCJSResult, options);
+  assertTypeScriptESMResult(code, expectedESMResult, options);
 }
 
 describe("typescript transform", () => {
@@ -3820,4 +3824,138 @@ describe("typescript transform", () => {
       },
     );
   });
+
+  it("respects keepUnusedImports for import statements", () => {
+    assertTypeScriptImportResult(
+      `
+      import A, {b} from './module1';
+      import * as C from './module2';
+      import {type d, e, f, type g} from './module3';
+      import type {h} from './module4';
+      import type * as I from './module5';
+      import {type j} from './module6';
+      import {} from './module7';
+    `,
+      {
+        expectedESMResult: `
+      import A, {b} from './module1';
+      import * as C from './module2';
+      import { e, f,} from './module3';
+
+
+      import {} from './module6';
+      import {} from './module7';
+    `,
+        expectedCJSResult: `"use strict";${IMPORT_WILDCARD_PREFIX}${IMPORT_DEFAULT_PREFIX}
+      var _module1 = require('./module1'); var _module12 = _interopRequireDefault(_module1);
+      var _module2 = require('./module2'); var C = _interopRequireWildcard(_module2);
+      var _module3 = require('./module3');
+      
+      
+      require('./module6');
+      require('./module7');
+    `,
+        options: {keepUnusedImports: true},
+      },
+    );
+  });
+
+  it("respects keepUnusedImports for export statements", () => {
+    assertTypeScriptImportResult(
+      `
+      type T = number;
+      type U = number;
+      type V = number;
+      type W = number;
+      type X = number;
+      type Y = number;
+      type Z = number;
+      export {T};
+      export {type U};
+      export type {V};
+      export {type W, X, type Y};
+      export default Z;
+    `,
+      {
+        expectedESMResult: `
+      
+
+
+
+
+
+
+      export {T};
+      export {};
+      ;
+      export { X,};
+      export default Z;
+    `,
+        expectedCJSResult: `"use strict";${ESMODULE_PREFIX}
+      
+
+
+
+
+
+
+      exports.T = T;
+      
+      ;
+      exports.X = X;
+      exports. default = Z;
+    `,
+        options: {keepUnusedImports: true},
+      },
+    );
+  });
+
+  it("respects keepUnusedImports for re-export statements", () => {
+    assertTypeScriptImportResult(
+      `
+      export {type T} from './module1';
+      export type {U} from './module2';
+      export {} from './module3';
+      export {type V, W} from './module4';
+      export * as foo from './module5';
+      export type * as bar from './module6';
+    `,
+      {
+        expectedESMResult: `
+      export {} from './module1';
+      ;
+      export {} from './module3';
+      export { W} from './module4';
+      export * as foo from './module5';
+      ;
+    `,
+        expectedCJSResult: `"use strict";${ESMODULE_PREFIX}${IMPORT_WILDCARD_PREFIX}${CREATE_NAMED_EXPORT_FROM_PREFIX}
+      require('./module1');
+      ;
+      require('./module3');
+      var _module4 = require('./module4'); _createNamedExportFrom(_module4, 'W', 'W');
+      var _module5 = require('./module5'); var _module52 = _interopRequireWildcard(_module5); exports.foo = _module52;
+      ;
+    `,
+        options: {keepUnusedImports: true},
+      },
+    );
+  });
+
+  it("respects keepUnusedImports for import = require syntax", () => {
+    assertTypeScriptImportResult(
+      `
+      import A = require("./A");
+    `,
+      {
+        expectedESMResult: `${CREATE_REQUIRE_PREFIX}
+      const A = _require("./A");
+    `,
+        expectedCJSResult: `"use strict";
+      const A = require("./A");
+    `,
+        options: {keepUnusedImports: true, injectCreateRequireForImportRequire: true},
+      },
+    );
+  });
 });