Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Feb 2, 2026

  • Understand the issue: tsgo emits a require statement when a value is imported without the type keyword but is only used in type-level positions
  • Investigate root cause using print debugging
  • Identify issue: isElisionBlocked returns true when TypeEraserTransformer modifies import clause
  • Fix importelision.go to elide imports even when isElisionBlocked is true if import clause becomes nil
  • Run full test suite - all tests pass
  • Multiple diff files deleted indicating tsgo now matches TypeScript behavior
  • Code review and security checks passed
  • Remove redundant test case since existing submodule tests cover the behavior
  • Simplify import elision logic by removing unnecessary conditional checks
Original prompt

This section details on the original issue you should resolve

<issue_title>tsgo emits a require statement when a value is imported without the type keyword but only used in type-level positions</issue_title>
<issue_description>## Steps to reproduce

Minimal reproduction: https://github.com/psm14/tsgo-type-only-import-reproduction

This extra require statement can cause different behavior at runtime if the imported file performs any top-level side effects.

// Import Value as a value (no `type` keyword), but only use it in type positions
import { Value, type ValueData } from "./provider";

// Value is ONLY used in type positions:
export interface Record {
  getValue(): Value; // return type
  setValue(value: Value): void; // parameter type
  readonly currentValue: Value; // type annotation
}

export function processRecord(
  value: Value, // parameter type
  callback: (result: Value) => void, // parameter type in callback
): Value {
  // return type
  callback(value);
  return value;
}

Behavior with typescript@5.9

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseProcessor = void 0;
exports.processRecord = processRecord;
// NO require("./provider")

function processRecord(value, callback) {
  callback(value);
  return value;
}

class BaseProcessor {
  constructor() {
    this.current = null;
  }
}
exports.BaseProcessor = BaseProcessor;

Behavior with tsgo

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseProcessor = void 0;
exports.processRecord = processRecord;
require("./provider"); // <-- EMITTED!

function processRecord(value, callback) {
  callback(value);
  return value;
}

class BaseProcessor {
  current = null;
}
exports.BaseProcessor = BaseProcessor;

</issue_description>

<agent_instructions>Create a test case out of the linked repo that shows the bug first, commit that, then work on the issue.</agent_instructions>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 6 commits February 2, 2026 23:53
Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Found that tsgo incorrectly emits require() when:
- Import contains both 'type' and non-type specifiers
- Non-type specifier is only used in type positions

Minimal repro: import { Value, type ValueData } from './provider';
where Value is only used as a type annotation.
Identified that the bug occurs specifically when:
- Import statement contains BOTH type-only and non-type specifiers
- The non-type specifier is only used in type positions

Most likely cause: Import processing logic doesn't properly distinguish
between type-level and value-level references when marking aliases as
referenced. The presence of a type-only import in the same statement
seems to trigger incorrect reference marking for non-type imports.

Compared with TypeScript source and found that tsgo is missing logic
in markSymbolOfAliasDeclarationIfTypeOnly to check if alias targets
resolve to type-only declarations.
Created testdata/repro-import-elision-bug/ with:
- provider.ts: Exports Value class and ValueData type
- broken.ts: Demonstrates bug with mixed type/non-type imports
- working.ts: Shows correct behavior without type import
- README.md: Instructions for reproducing the bug

The broken.ts file imports { Value, type ValueData } where Value
is only used in type positions, and incorrectly emits require().
Concise summary of the import elision bug investigation including:
- Clear reproduction steps
- Root cause analysis
- Key findings and trigger conditions
- Next steps for implementing a fix
- References to relevant code and test files
Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix tsgo emitting require statement for type-level imports Add test case and partial fix for import elision with type-only usage Feb 3, 2026
Copilot AI requested a review from jakebailey February 3, 2026 00:42
@jakebailey
Copy link
Member

@copilot as you said, this is a partial fix. Think harder and figure out how to fully fix it. Cross compare against checker.ts. Use print debugging with fmt.Println and go test -v if you have to.

…ormers

When type-only import specifiers (like `type ValueData`) are present,
the TypeEraserTransformer modifies the import clause before the
ImportElisionTransformer runs. This caused isElisionBlocked to return
true, preventing proper elision of the import declaration.

The fix ensures that:
1. The import clause is always visited first
2. If the import clause becomes nil (all bindings elided), the import
   declaration is elided regardless of isElisionBlocked
3. If the import clause changed, the import declaration is updated

This makes tsgo match TypeScript's behavior, where imports only used
in type positions are properly elided.

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 3, 2026

@copilot as you said, this is a partial fix. Think harder and figure out how to fully fix it. Cross compare against checker.ts. Use print debugging with fmt.Println and go test -v if you have to...

Fixed in e624128. The root cause was that isElisionBlocked returned true when the TypeEraserTransformer had already modified the import clause (by removing type-only specifiers), which prevented proper elision. The fix now visits the import clause first and elides the entire import declaration if the import clause becomes nil, regardless of isElisionBlocked. This also fixed several other baseline diffs where tsgo now matches TypeScript's behavior.

The test case is covered by other existing submodule tests like
importSpecifiers1.ts and nodeModulesImportAttributesModeDeclarationEmitErrors.

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
…check

The conditional checks for isElisionBlocked and importClause comparison were
unnecessary since we always want to return the UpdateImportDeclaration result
after the importClause nil check.

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
@jakebailey jakebailey marked this pull request as ready for review February 3, 2026 20:00
Copilot AI review requested due to automatic review settings February 3, 2026 20:00
@jakebailey jakebailey changed the title Add test case and partial fix for import elision with type-only usage Fix import elision with type-only usage Feb 3, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes incorrect import elision where tsgo could emit runtime require(...) calls for imports that are only used in type positions, aligning emitted output more closely with the TypeScript reference baselines.

Changes:

  • Update ImportElisionTransformer to drop ImportDeclarations when the visited ImportClause is fully elided, preventing conversion into side-effect-only imports.
  • Elide type-only ImportSpecifiers directly in ImportElisionTransformer.
  • Update reference baselines and remove now-unnecessary .diff baselines where tsgo output converged with the reference behavior.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
internal/transformers/tstransforms/importelision.go Changes import elision behavior for ImportDeclaration and ImportSpecifier.
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=nodenext).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=nodenext).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=node20).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=node20).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=node18).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=node18).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=node16).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportModeDeclarationEmitErrors1(module=node16).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=nodenext).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=nodenext).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=node20).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=node20).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=node18).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=node18).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=node16).js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/nodeModulesImportAttributesModeDeclarationEmitErrors(module=node16).js Updates expected emitted JS to no longer include a require("pkg").
testdata/baselines/reference/submodule/conformance/importSpecifiers1.js.diff Removed baseline-diff file now that output matches reference.
testdata/baselines/reference/submodule/conformance/importSpecifiers1.js Updates expected emitted JS for d.js to export {};.

Comment on lines 42 to 53
case ast.KindImportDeclaration:
if !tx.isElisionBlocked(node) {
n := node.AsImportDeclaration()
// Do not elide a side-effect only import declaration.
// import "foo";
if n.ImportClause != nil {
importClause := tx.Visitor().VisitNode(n.ImportClause)
if importClause == nil {
return nil
}
return tx.Factory().UpdateImportDeclaration(n, n.Modifiers(), importClause, n.ModuleSpecifier, tx.Visitor().VisitNode(n.Attributes))
n := node.AsImportDeclaration()
// Do not elide a side-effect only import declaration.
// import "foo";
if n.ImportClause != nil {
importClause := tx.Visitor().VisitNode(n.ImportClause)
if importClause == nil {
return nil
}
return tx.Factory().UpdateImportDeclaration(n, n.Modifiers(), importClause, n.ModuleSpecifier, tx.Visitor().VisitNode(n.Attributes))
}
return tx.Visitor().VisitEachChild(node)
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the fix is to elide imports even when isElisionBlocked is true only when the visited ImportClause becomes nil, but the implementation removes the isElisionBlocked gate for all ImportDeclarations. This changes behavior for any import declaration that was modified earlier in the pipeline (including by custom before transforms) and may re-enable elision in cases the isElisionBlocked contract is meant to prevent. Consider restoring the isElisionBlocked check for the general case and only bypassing it for the specific scenario where the visited ImportClause becomes nil (or adjust isElisionBlocked to treat TypeEraser updates as non-blocking).

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description was not updated to reflect the later changes, unfortunately

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isElisionBlocked seems to only be needed for custom transformers, which we don't have, too.

@jakebailey jakebailey marked this pull request as draft February 4, 2026 03:25
@jakebailey
Copy link
Member

I investigated this more and figured out that the problem is that the old TS transform has been split into a type eraser and an import elider. And so, the "fix" is actually the removal of the isElisionBlocked call itself, which was originally designed to bail on import nodes that had been modified by custom transformers that happened before ours. But that's exactly what this looks like now, where there are two steps, and the second thinks that a previous step did the work because parts of the AST had been modified through erasing TS code.

The extra bit is that both contain basically the same code for erasing imports, which makes me think that we shouldn't have a separate import elider at all.

@jakebailey
Copy link
Member

Sent #2673 instead

@jakebailey jakebailey closed this Feb 4, 2026
@jakebailey jakebailey deleted the copilot/fix-tsgo-require-issue branch February 4, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tsgo emits a require statement when a value is imported without the type keyword but only used in type-level positions

4 participants