Repro
lib.ts:
export class A {}
export class B {}
export const NUM = 42;
main.ts:
import * as Lib from "./lib.js";
console.log("typeof Lib:", typeof Lib);
console.log("typeof Lib.A:", typeof Lib.A);
console.log("typeof Lib.NUM:", typeof Lib.NUM);
console.log("Lib.NUM:", Lib.NUM);
bun main.ts:
typeof Lib: object
typeof Lib.A: function
typeof Lib.NUM: number
Lib.NUM: 42
perry main.ts:
typeof Lib: boolean <- should be "object"
typeof Lib.A: undefined <- class is unreachable through namespace
typeof Lib.NUM: number <- primitive const works
Lib.NUM: 42 <- value reads correctly
So primitive consts via namespace import partially work (the actual value is correct, even though typeof Lib is "boolean"), but class exports are completely unreachable — Lib.A returns undefined.
Why this matters
Discovered while compiling @bradenmacdonald/s3-lite-client via #551. The package's mod.ts ends with:
export * as S3Errors from "./errors.js";
Which the consumer uses as:
import { S3Errors } from "@bradenmacdonald/s3-lite-client";
catch (err) {
if (err instanceof S3Errors.ServerError && err.statusCode === 404) ...
}
Every instanceof S3Errors.ServerError check fails because S3Errors.ServerError is undefined. Every error-bucket-namespace pattern silently breaks.
This pattern is widespread — import * as fs from "node:fs" (Node-style; Perry handles node: differently but the idiom carries over), import * as Sentry from "@sentry/node", every package that exposes a "namespace of related things" instead of flat exports.
Three observations
-
typeof Lib is "boolean" — meaning the namespace itself is being represented as a NaN-boxed bool tag. Likely the import is initializing Lib to TAG_TRUE or TAG_FALSE instead of building a namespace object.
-
Primitive const access works despite the broken typeof. Suggests there's a fallback path that resolves Lib.NUM directly to the underlying export's storage location — bypassing the (broken) namespace object lookup.
-
Class access doesn't go through that same fallback. The Lib.A property read returns undefined. This may be related to how class-refs are NaN-boxed via INT32_TAG (per recent v0.5.622 wiring) — the namespace fallback path doesn't handle the INT32-tagged form.
Suggested implementation
Search where import * as Ns from "..." is lowered (likely crates/perry-hir/src/lower.rs or compile/resolve.rs) and verify it creates an actual namespace object whose own-properties enumerate every named export of the source module. The current behavior implies the lowering elides the namespace object entirely and special-cases primitive value resolution at usage sites — which is why classes leak.
Acceptance
The repro at the top prints object / function / number / 42 matching bun. Plus regressions:
Lib.B instanceof Lib.A works for class hierarchies
Lib.helperFn() calls work for function exports
for (const k in Lib) enumerates exported names
Object.keys(Lib) returns the exports
import { S3Errors } from "..." where S3Errors came from a re-export * as chain
Refs #551
Repro
lib.ts:main.ts:bun main.ts:perry main.ts:So primitive consts via namespace import partially work (the actual value is correct, even though
typeof Libis "boolean"), but class exports are completely unreachable —Lib.Areturns undefined.Why this matters
Discovered while compiling
@bradenmacdonald/s3-lite-clientvia #551. The package'smod.tsends with:Which the consumer uses as:
Every
instanceof S3Errors.ServerErrorcheck fails becauseS3Errors.ServerErroris undefined. Every error-bucket-namespace pattern silently breaks.This pattern is widespread —
import * as fs from "node:fs"(Node-style; Perry handlesnode:differently but the idiom carries over),import * as Sentry from "@sentry/node", every package that exposes a "namespace of related things" instead of flat exports.Three observations
typeof Libis"boolean"— meaning the namespace itself is being represented as a NaN-boxed bool tag. Likely the import is initializingLibto TAG_TRUE or TAG_FALSE instead of building a namespace object.Primitive const access works despite the broken
typeof. Suggests there's a fallback path that resolvesLib.NUMdirectly to the underlying export's storage location — bypassing the (broken) namespace object lookup.Class access doesn't go through that same fallback. The
Lib.Aproperty read returns undefined. This may be related to how class-refs are NaN-boxed via INT32_TAG (per recent v0.5.622 wiring) — the namespace fallback path doesn't handle the INT32-tagged form.Suggested implementation
Search where
import * as Ns from "..."is lowered (likelycrates/perry-hir/src/lower.rsorcompile/resolve.rs) and verify it creates an actual namespace object whose own-properties enumerate every named export of the source module. The current behavior implies the lowering elides the namespace object entirely and special-cases primitive value resolution at usage sites — which is why classes leak.Acceptance
The repro at the top prints
object/function/number/42matching bun. Plus regressions:Lib.B instanceof Lib.Aworks for class hierarchiesLib.helperFn()calls work for function exportsfor (const k in Lib)enumerates exported namesObject.keys(Lib)returns the exportsimport { S3Errors } from "..."whereS3Errorscame from a re-export * aschainRefs #551