Skip to content

imports: import * as Ns from "./mod.js" produces non-object namespace; class members unreachable #574

@proggeramlug

Description

@proggeramlug

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 unreachableLib.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

  1. 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.

  2. 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.

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions