SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

Report duplicate declarations for import bindings kept in TypeScript output (#31240)

### What

Parser fuzzing found two inputs where the transpiler's own output fails
to re-parse (both are duplicate-binding early errors per spec, and
errors in `tsc`):

```js
// 1. the same name imported twice
new Bun.Transpiler({ loader: "tsx" }).transformSync(`import{Observable}from""\nimport{Observable} from "x"`);
// succeeded, emitted both imports; re-parsing the output fails with
// error: "Observable" has already been declared

// 2. an import colliding with a class declaration
new Bun.Transpiler({ loader: "ts" }).transformSync(`import { Foo } from ".";export class Foo {}`);
// succeeded, emitted both the import and the class
```

### Cause

`Scope::can_merge_symbol_kinds` deliberately lets any later declaration
take over the name of an import binding when TypeScript is enabled (the
import may be type-only, e.g. `import type`-less interface imports,
namespace declaration merging). That lenience is only sound when the
shadowed import is later elided as unused. The runtime and the bundler
always trim unused imports for TS, but `Bun.Transpiler` defaults to
keeping them, so the shadowed import binding was printed right next to
the declaration that replaced it — output that declares the same name
twice.

### Fix

In `ImportScanner::scan` (src/js_parser/scan/scan_imports.rs), after
unused-import elision (which runs post-visit, once use counts are
known), each import binding still in the statement whose symbol was
replaced (its `link` is set by `ReplaceWithNew`) is checked against the
module scope's member for that name; when the member points at the final
symbol in that chain of replacements, it's reported as `"X" has already
been declared` at the re-declaration (the member's location) with a note
at the import. Compiler-generated symbols that reuse a name (the JSX
runtime auto-imports in bundle mode) don't set that link, so they can't
trip the check. No parser state is added, `declare_symbol` is untouched,
and non-TypeScript files are unaffected.

So the error fires when a re-declared import binding is kept in the
output, and the existing lenience is preserved whenever the import is
actually elided:

| input (ts/tsx) | before | after |
| --- | --- | --- |
| `import{Observable}from""` + `import{Observable} from "x"` (imports
kept) | emits invalid output | error: already declared |
| `import { Foo } from "."; export class Foo {}` (imports kept) | emits
invalid output | error: already declared |
| same inputs with `trimUnusedImports: true` (runtime/bundler default
for TS) | shadowed import elided | unchanged |
| `import type { Foo } from "x"; class Foo {}` | import erased, `class
Foo {}` | unchanged |
| `import { type Foo, Bar } from "x"; class Foo {}` | type specifier
erased | unchanged |
| `import { Foo } from "x"; declare class Foo {}` / interface /
overload-only signatures | import kept, no conflict emitted | unchanged
|
| `import { Foo } from "x"; namespace Foo {}` at runtime (valid TS
declaration merging) | import elided, namespace emitted | unchanged |
| `import { Foo } from "x"; import Foo = Bar.Baz` (unused, imports kept)
| import-equals dropped by its own elision, import emitted | error:
already declared (tsc rejects this input too) |
| plain JS loaders | already errored | unchanged |

`Bun.Transpiler.scan()` with a TS loader now also reports the error for
these inputs (it already did for JS loaders); `scanImports()` is
unchanged.

### Verification

- `bun bd test test/bundler/transpiler/transpiler.test.js` — new tests
`re-declaring an import binding that is kept in the output is an error`
/ `re-declaring an elided import binding is allowed` pass; the
error-case test fails on a build without the fix.
- Full `test/bundler/transpiler/`, `test/js/bun/transpiler/`,
`test/bundler/esbuild/ts.test.ts`,
`test/bundler/esbuild/importstar_ts.test.ts`, and
`test/js/bun/resolve/import-defer.test.ts` pass.
- Runtime and `bun build` behavior on the repro files is unchanged
(shadowed imports still elided there).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
R
robobun committed
52898c8457143c45f3082859af4672986d2df934
Parent: 3c92be8
Committed by GitHub <noreply@github.com> on 5/23/2026, 8:11:09 AM