Summary
new globalThis.X(...) ignores the globalThis. qualifier and resolves X by bare name. When a module legitimately imports a user class that shadows a global, the qualified form builds the user class instead of the intrinsic.
The aliased form works, which is what makes this two lowerings rather than one broken one:
new globalThis.Event("ping") // perry: the USER class
const E = globalThis.Event; new E("ping") // perry: the intrinsic — correct
Reproducer
ev.ts:
export class Event { readonly tag = "user-Event" }
main.ts:
// @ts-nocheck
import { Event } from "./ev.js" // a legitimate, explicit import that shadows the bare name
const e: any = new globalThis.Event("ping")
console.log("tag:", e.tag)
console.log("type:", e.type)
console.log("ctor-name:", e.constructor?.name)
console.log("is-user-class:", e instanceof Event)
Measured (perry @ v0.5.1579 + #10356, vs bun 1.3.14)
| cell |
bun |
perry |
|
e.tag |
undefined |
user-Event |
✗ |
e.type |
ping |
undefined |
✗ |
e.constructor?.name |
Event |
Event |
ok (both classes are named Event — this cell cannot discriminate) |
e instanceof Event |
false |
true |
✗ decisive |
The last cell is the proof: the value built through globalThis.Event is an instance of the imported class.
Wider matrix on the same fixture — only the direct qualified new is affected:
| cell |
bun |
perry |
new Event().tag (the local binding, legitimately the user class) |
user-Event |
user-Event ok |
typeof globalThis.Event |
function |
function ok |
globalThis.Event?.name |
Event |
Event ok |
new globalThis.Event("ping").type |
ping |
undefined ✗ |
const E = globalThis.Event; new E("ping").type |
ping |
ping ok |
new globalThis.CustomEvent("x",{detail:7}).detail |
7 |
7 ok |
With no shadowing user class in scope, every globalThis.-qualified form is correct (Event, CustomEvent, URL, Headers all pass). The defect only appears once a same-named binding exists in the module.
Why it matters
globalThis.X is the idiom people reach for precisely to escape a local shadow — it is the documented workaround for this situation, so silently resolving it to the shadow defeats its only purpose.
OpenCode's graph has many such shadows: packages/sdk/js/src/v2/gen/sdk.gen.ts exports Event, File and Request; packages/core exports Error twice; Effect exports Request, WebSocket, FormData and Storage.
Relationship to other issues
Distinct from #10356. There the class was never imported and should not have been in scope at all. Here the import is legitimate and the bare name should be the user class — but the globalThis.-qualified read must still reach the intrinsic.
Same family as #10303 (global.x reads collapsing to the intrinsic surface while the write landed): a qualified access and its unqualified sibling take different lowerings, and only one honors the qualifier. The asymmetry between the direct and aliased forms above is the signature.
Summary
new globalThis.X(...)ignores theglobalThis.qualifier and resolvesXby bare name. When a module legitimately imports a user class that shadows a global, the qualified form builds the user class instead of the intrinsic.The aliased form works, which is what makes this two lowerings rather than one broken one:
Reproducer
ev.ts:main.ts:Measured (perry @ v0.5.1579 + #10356, vs bun 1.3.14)
e.tagundefineduser-Evente.typepingundefinede.constructor?.nameEventEventEvent— this cell cannot discriminate)e instanceof EventfalsetrueThe last cell is the proof: the value built through
globalThis.Eventis an instance of the imported class.Wider matrix on the same fixture — only the direct qualified
newis affected:new Event().tag(the local binding, legitimately the user class)user-Eventuser-Eventoktypeof globalThis.EventfunctionfunctionokglobalThis.Event?.nameEventEventoknew globalThis.Event("ping").typepingundefined✗const E = globalThis.Event; new E("ping").typepingpingoknew globalThis.CustomEvent("x",{detail:7}).detail77okWith no shadowing user class in scope, every
globalThis.-qualified form is correct (Event,CustomEvent,URL,Headersall pass). The defect only appears once a same-named binding exists in the module.Why it matters
globalThis.Xis the idiom people reach for precisely to escape a local shadow — it is the documented workaround for this situation, so silently resolving it to the shadow defeats its only purpose.OpenCode's graph has many such shadows:
packages/sdk/js/src/v2/gen/sdk.gen.tsexportsEvent,FileandRequest;packages/coreexportsErrortwice; Effect exportsRequest,WebSocket,FormDataandStorage.Relationship to other issues
Distinct from #10356. There the class was never imported and should not have been in scope at all. Here the import is legitimate and the bare name should be the user class — but the
globalThis.-qualified read must still reach the intrinsic.Same family as #10303 (
global.xreads collapsing to the intrinsic surface while the write landed): a qualified access and its unqualified sibling take different lowerings, and only one honors the qualifier. The asymmetry between the direct and aliased forms above is the signature.