Skip to content

parser(ts): keep "export {}" ESM marker when all specifiers are type-only - #34251

Open
robobun wants to merge 4 commits into
mainfrom
farm/3a18d6f0/ts-type-only-export-esm-marker
Open

robobun wants to merge 4 commits into
mainfrom
farm/3a18d6f0/ts-type-only-export-esm-marker

Conversation

@robobun

@robobun robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

When every specifier in an export { ... } clause is type-only, Bun erased the whole statement:

// input
export { type foo }
// bun build --no-bundle (before)
/* empty output */

// esbuild / tsc
export {};

Dropping the statement entirely loses the ESM module marker, so downstream tools and runtimes may treat the output as a script rather than a module (top-level this, strict mode, etc).

A related bug: import { type as } from "mod" (a type-only import of the identifier as) was falling through a missing else branch in parse_import_clause and leaking a bare import "mod"; into the output instead of being dropped.

Fix

  • parse_stmt.rs: remove the early-return to S::TypeScript for the non-from export { ... } case and fall through to an empty S::ExportClause, which prints as export {};. The export { type foo } from "bar" path still drops the whole re-export since there is nothing to import.
  • parse_import_export.rs: add the missing else branch for import { type as } so had_type_only_imports is set and the statement is dropped.
  • parse_entry.rs / visit_stmt.rs: drop the resulting export {}; clause whenever another export statement (or top-level await) already marks the output as ESM, so type-only exports alongside real exports no longer leave a redundant marker behind. This matches tsc; esbuild always keeps the redundant clause. The check is order-independent and TypeScript-only.

Verification

test/bundler/transpiler/transpiler.test.js "type only exports" now covers:

Input Before After
export { type x } (empty) export {};
export { type as } (empty) export {};
export { type x } from 'mod' (empty) (empty)
export const a = 1; export { type B } export const a = 1; export const a = 1;
export { type B }; export const a = 1 export const a = 1; export const a = 1;
export { type A }; export { b } export { b }; export { b };
export { type A }; export { type B } (empty) export {};
import { type as } from 'mod' import"mod"; (empty)

The previous assertion expectPrinted_("export { type x };", "") is updated to expect "export {}". Two bundler_promiseall_deadcode snapshots update their debugId hash only (fixture entries pair await with a now-redundant export {};; bundled code is unchanged).


[review] gate passed · iteration 2 · 8 files touched

fails on main (without fix)
ASAN without fix: 4 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_promiseall_deadcode.test.ts test/bundler/transpiler/transpiler.test.js test/regression/issue/cyclic-imports-async-bundler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (8b8edc35e)

test/bundler/bundler_promiseall_deadcode.test.ts:
72 |       },
73 |     },
74 |     onAfterBundle(api) {
75 |       const bundled = api.readFile("out.js");
76 | 
77 |       expect(bundled).toMatchInlineSnapshot(`
                           ^
error: expect(received).toMatchInlineSnapshot(expected)

@@ -86,9 +86,9 @@
  
  // entry.ts
  var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() = exports_AsyncEntryPoint);
  AsyncEntryPoint2();
  
- //# debugId=8E8163A4572579B664756E2164756E21
+ //# debugId=42062903F19477CF64756E2164756E21
  //# sourceMappingURL=out.js.map
  "
  

- Expected 
... (truncated)

release without fix: 1 failed, 22 skipped
bun test v1.4.0-canary.1 (ab13882c1)

test/bundler/bundler_promiseall_deadcode.test.ts:
(pass) bundler > bundler/__promiseAll is tree-shaken when only one async import exists but __esm remains [27.93ms]
(pass) bundler > bundler/__promiseAll is included when multiple async imports exist with __esm [18.50ms]
(pass) bundler > bundler/__promiseAll is tree-shaken when no async imports despite circular deps with __esm [18.27ms]

test/regression/issue/cyclic-imports-async-bundler.test.js:
(pass) cyclic imports with async dependencies should generate async wrappers [27.01ms]

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [0.07ms]
(pass) Bun.Transpiler > normalizes \r\n [0.12ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [0.07ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.16ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.03ms]
(pass) Bun.Transpiler > property access inlining > works [0.03ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.02ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [0.04ms]
(pa
... (truncated)
passes on PR (with fix)
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_promiseall_deadcode.test.ts test/bundler/transpiler/transpiler.test.js test/regression/issue/cyclic-imports-async-bundler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (8b8edc35e)

test/bundler/bundler_promiseall_deadcode.test.ts:
(pass) bundler > bundler/__promiseAll is tree-shaken when only one async import exists but __esm remains [1254.48ms]
(pass) bundler > bundler/__promiseAll is included when multiple async imports exist with __esm [980.13ms]
(pass) bundler > bundler/__promiseAll is tree-shaken when no async imports despite circular deps with __esm [832.64ms]

test/regression/issue/cyclic-imports-async-bundler.test.js:
(pass) cyclic imports with async dependencies should generate async wrappers [1153.92ms]

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > ha
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 751ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (truncated)
diff hotspot
src/js_parser/p.rs                                 | 10 ++++++
 src/js_parser/parse/parse_entry.rs                 | 41 ++++++++++++++++++++++
 src/js_parser/parse/parse_import_export.rs         |  3 ++
 src/js_parser/parse/parse_stmt.rs                  | 11 ++----
 src/js_parser/visit/visit_stmt.rs                  | 20 +++++++++++
 test/bundler/bundler_promiseall_deadcode.test.ts   |  4 +--
 test/bundler/transpiler/transpiler.test.js         | 40 ++++++++++++++++++++-
 .../issue/cyclic-imports-async-bundler.test.js     |  2 +-
 8 files changed, 118 insertions(+), 13 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/js_parser/p.rs                                            6      2      0
src/js_parser/parse/parse_entry.rs                            2      3      0
src/js_parser/parse/parse_import_export.rs                    2      1      0
src/js_parser/parse/parse_stmt.rs                             5      1      0
src/js_parser/visit/visit_stmt.rs                             4      2      0
test/bundler/bundler_promiseall_deadcode.test.ts              0      0      0
test/bundler/transpiler/transpiler.test.js                    1      4      0
…t/regression/issue/cyclic-imports-async-bundler.test.js      0      0      0

…only

"export { type foo }" was being erased entirely when every specifier was
type-only, which loses the ESM module marker. Downstream tools and
runtimes then see a script instead of a module (affecting top-level
this, strict mode, etc). esbuild and tsc both emit "export {}" here.

The early return to S::TypeScript in the non-"from" branch is removed so
an empty S::ExportClause is emitted instead. The "from" branch still
drops the whole re-export since there is nothing to import.

Also fixes "import { type as }" (a type-only import of the identifier
"as") which was falling through without marking the clause as type-only
and leaking a bare "import 'mod'" into the output.
@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with bun build --no-bundle on a file containing only export { type foo }: output was empty, now export {};. Redundant empty clauses are dropped when another export (or TLA) already marks the file as ESM, matching tsc. The export default <TypeName> edge case raised in review is handled in 8b8edc3.

Tests pass locally: transpiler.test.js (171/0), bundler/esbuild/ts.test.ts, bundler_edgecase, bundler_regressions, bundler_promiseall_deadcode, regression/issue/cyclic-imports-async-bundler. Gate stamped (fails on main with src/ reverted, passes on ASAN + release with the fix).

CI build 73455: the only red lanes are test/js/node/test/parallel/test-net-connect-memleak.js (pre-existing on main) and test/cli/run/require-cache.test.ts on darwin aarch64 (RSS-threshold leak check on a .js fixture; the new scan is TS-only and doesn't run there, and this test was already flaky on build 73304 before the scan existed). Both are being handled separately. The rest are retry flakes. Diff is green on everything it touches.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 118060f3-1435-45e3-9f00-2df5e1ad57c5

📥 Commits

Reviewing files that changed from the base of the PR and between be77b65 and ab13882.

📒 Files selected for processing (7)
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/parse/parse_import_export.rs
  • src/js_parser/parse/parse_stmt.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/bundler_promiseall_deadcode.test.ts
  • test/bundler/transpiler/transpiler.test.js

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:11 PM PT - Jul 15th, 2026

❌ @robobun, your commit 8b8edc3 has 2 failures in Build #73455 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34251

That installs a local version of the PR into your bun-34251 executable, so you can run:

bun-34251 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. "Exports named not found" in Nest.JS when only types are exported #15006 - Type-only exports in NestJS cause "Export named not found" because the transpiler erases the entire export statement, dropping the ESM marker
  2. Different output when logging this #32167 - Top-level this logs wrong value because type-only export erasure removes the ESM marker, causing the file to be treated as a script instead of a module

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #15006
Fixes #32167

🤖 Generated with Claude Code

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Neither of the suggested issues is fixed by this change:

Leaving the PR body as-is.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — small, well-scoped parser fix that aligns type-only export {}/import {} erasure with esbuild/tsc.

What was reviewed:

  • parse_stmt.rs: removing the S::TypeScript early-return falls through to the same empty S::ExportClause node that a literal export {} already produces, so no new print/visit shape; has_es_module_syntax = true now correctly fires.
  • parse_import_export.rs: traced import { type as } through parse_import_clause — the added else covers the only fall-through case (next token is } or ,); had_type_only_exports is still consumed by the from path.
  • Test matrix covers the as/as as/from/mixed variants and updates the one existing assertion whose expected output changed.
Extended reasoning...

Overview

Two localized parser changes plus test coverage:

  • src/js_parser/parse/parse_stmt.rs (t_export, TOpenBrace arm): removes the IS_TYPESCRIPT_ENABLED && clauses.is_empty() && had_type_only_exports early-return to S::TypeScript {} for the non-from form, so export { type x } now falls through to an empty S::ExportClause and prints as export {}; (the ESM module marker). The export { type x } from "mod" path retains its drop-entirely behavior.
  • src/js_parser/parse/parse_import_export.rs (parse_import_clause): adds the missing else arm for import { type as } (i.e., type followed by as followed by }/,), which previously fell through without pushing an item and without setting had_type_only_imports, causing t_import to emit a bare import "mod";.
  • test/bundler/transpiler/transpiler.test.js: expands the "type only exports" block with a variant matrix and updates the one pre-existing assertion (export { type x } → "") to the new expected output.

Security risks

None. Pure syntactic transform in the TS parser; no I/O, no user-controlled allocation sizes, no FFI/JSC interaction.

Level of scrutiny

Low-medium. The diff is small (net −9/+21 in src/), mechanically follows esbuild's reference implementation (cited in the PR), and the resulting AST node (S::ExportClause { items: [] }) is already the exact shape produced by a user-written export {}, so the printer and visit passes are unchanged and already exercised. I traced the import { type as } branch by hand: after consuming type then as, the next token is }/,, so both existing if guards fail — the new else is the only sink. had_type_only_exports remains live via the from path at parse_stmt.rs:1261, so no dead code is introduced.

Other factors

  • robobun confirms tests pass on the debug build and fail on USE_SYSTEM_BUN=1.
  • The test additions cover the sibling forms called out in CLAUDE.md ("cover the variant matrix"): type as, type x as y, multiple type specifiers, unicode-escaped type, from vs non-from, and the mixed import { type as, x } case.
  • No prior human review comments; no CODEOWNER on these paths.

@dylan-conway

Copy link
Copy Markdown
Member

@robobun what if there are other export statements that do not get eliminated? would we end up with something like export { foo };\n export {};? if so, can we avoid the empty export {} statement because it does nothing?

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Yes, and that exactly matches esbuild:

$ printf 'export const foo = 1;\nexport { type Bar };\n' | esbuild --loader=ts
export const foo = 1;
export {};

Same for export { foo }; export { type Bar }; and export { type A }; export { type B }; (two empty clauses). The extra export {}; is redundant but harmless at runtime.

tsc is smarter here and drops the empty clause when there is already another ESM statement in the file (in either order), so matching tsc would mean a whole-file check rather than a per-statement decision. esbuild has never done that optimization, and bun already leaves a literal user-written export {} in the same spot:

$ printf 'export const foo = 1;\nexport {};\n' | bun build --no-bundle /dev/stdin --loader=ts
export const foo = 1;
export {};

So this PR just makes export { type Bar } behave the same as the export {} it lowers to. I'm happy to add the "drop empty export clauses when the file has other ESM syntax" optimization on top (it belongs in the visit pass since it needs to look at the whole file), either here or as a follow-up. Let me know which you'd prefer.

@dylan-conway

Copy link
Copy Markdown
Member

@robobun we should not match esbuild here. if there's already an esm export statement we should be smart like tsc and drop the empty one. the empty statement is harmful because it hurt bundle size and parsing/lexing

… exists

When type-stripping leaves an "export {}" clause behind, drop it if the
output already contains another export statement (or top-level await)
that marks the file as ESM. This matches tsc and avoids dead weight in
the output; esbuild always keeps the redundant clause.

The decision is order-independent: a pre-visit scan records which
export forms definitely survive, and the visit pass tracks remaining
export clauses so an empty clause appearing before a non-empty one is
still dropped while never losing the marker entirely.

JavaScript input is untouched.
@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 1b7d02d. Empty export {} clauses are now dropped whenever another export (or top-level await) already marks the output as ESM, regardless of source order:

export const a = 1; export { type B };  ->  export const a = 1;
export { type B }; export const a = 1;  ->  export const a = 1;
export { type A }; export { b };        ->  export { b };
export { type A }; export { type B };   ->  export {};          (one marker kept)
export { type A };                      ->  export {};          (only marker, kept)

The check runs in TypeScript only; a literal export {} in a .js file is left alone.

Implementation: a pre-visit scan records exports that definitely survive (export const/let/var/function/class/default, export *, export from, enum/namespace, TLA). During visit, S::ExportClause tracks how many sibling clauses still have items so an empty clause visited first can defer to a later non-empty one without ever losing the marker when everything strips to empty.

The two bundler_promiseall_deadcode snapshots updated are debugId-only (the fixture entry has await plus a now-redundant export {};, so the source map shifts); bundled code is byte-identical.

Comment thread src/js_parser/parse/parse_entry.rs Outdated
"export default Foo" where Foo is a local interface/type alias is dropped
during visit, so it is not safe to count in the pre-visit scan as a
surviving export. The pre-scan now only counts an SExportDefault whose
value is not a bare identifier; the visit pass sets the flag once the
default actually survives the type-only check.

Also update the cyclic-imports-async-bundler snapshot (debugId only; the
fixture pairs top-level await with a redundant export {} in a .ts entry).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix in 8b8edc3 correctly addresses my earlier finding — I re-traced the four ordering combinations of export default <ident> × export {} and the ESM marker is now preserved in each. Leaving final sign-off to dylan-conway since he's already engaged on the redundant-clause optimization.

What was reviewed:

  • Verified the pre-scan now excludes bare-identifier SExportDefault and that s_export_default sets has_nonempty_export_stmt only after passing the local-type-name drop check.
  • Confirmed SNamespace in the pre-scan is safe (type-only namespaces become S::TypeScript at parse time as stated).
  • Checked the remaining_export_clauses_with_items decrement ordering vs. remove_for_tree_shaking — the only lossy interaction requires replace_exports + tree-shaking, an internal HMR path.
Extended reasoning...

Overview

This PR changes TypeScript type-stripping in the JS parser so that export { type Foo } lowers to export {} (preserving the ESM module marker) instead of vanishing entirely. A second commit, requested by dylan-conway, adds a whole-file optimization that drops the resulting empty export {} clause when another surviving export or top-level await already marks the file as ESM. A third commit fixes a regression I flagged where export default <TypeName> was being counted as "definitely surviving" in the pre-visit scan even though visit can drop it.

Files touched: two new fields on the P parser struct, a pre-visit scan in parse_entry.rs, drop logic in visit_stmt.rs::s_export_clause, a flag-set in s_export_default, removal of an early return in parse_stmt.rs, and a one-line missing-else fix in parse_import_export.rs for import { type as }. Three snapshot files update only their debugId hash.

Security risks

None. This is TypeScript type-erasure output shaping; no untrusted-input parsing surface changes, no allocation sizing, no FFI.

Level of scrutiny

High — this is the JS parser, on the hot path for every .ts file Bun touches. The optimization introduces cross-statement state (has_nonempty_export_stmt, remaining_export_clauses_with_items) shared between a pre-visit scan and the visit pass, with an ordering-dependent invariant ("exports counted in the pre-scan must definitely survive visit"). My previous review caught a violation of that invariant for SExportDefault; the fix now excludes bare-identifier defaults from the pre-scan and sets the flag during visit only after the local-type-name check passes. I re-traced the added regression tests and the inverse orderings and they hold.

Other factors

  • dylan-conway is actively reviewing and specifically requested the redundant-clause optimization, so he should confirm the final shape matches what he had in mind.
  • Test coverage is thorough: 30+ new expectPrinted_ cases in transpiler.test.js covering source-order permutations, the export default <TypeName> regression, TLA, and the import { type as } fix. The PR body shows these fail on main and pass with the fix.
  • One acknowledged false negative: export {}; const Foo = 1; export default Foo; keeps the redundant marker because the pre-scan can't know Foo binds to a value. This is harmless (extra 10 bytes) and matches esbuild.
  • I noted a theoretical edge case where remove_for_tree_shaking in s_export_clause (which requires the internal replace_exports feature + tree-shaking + all specifiers replaced-and-removed) could drop a clause an earlier empty clause deferred to; not a practical concern for user code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants