Skip to content

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

Closed

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

  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

Activity

  1. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in v0.5.664 (320a162).

    Two surgical fixes in crates/perry-codegen/src/expr.rs:

    1. Namespace-member PropertyGet for class exports — when the property name is in class_ids, emit the INT32-tagged class-id NaN-box (mirrors Expr::ClassRef) instead of the function-getter call which silently returned undefined for class exports.

    2. Expr::TypeOf short-circuits four shapes that js_value_typeof would misclassify:

      • Namespace ExternFuncRef → "object" (was "boolean" via TAG_TRUE fallback)
      • Local class-ref → "function" (was "number" via INT32 path)
      • Imported class via class_ids → "function"
      • Namespace-member where property is a class → "function"

    Bonus: b instanceof Lib.A now works — Expr::InstanceOf strips the Ns. prefix when Ns is a namespace import and re-looks up the class id by bare name.

    Repro from the issue (typeof Lib: object / typeof Lib.A: function / typeof Lib.NUM: number / Lib.NUM: 42) matches bun byte-for-byte. Lib.B instanceof Lib.A works for hierarchies. Lib.helperFn() calls work. new Lib.A() constructs correctly.

    Note: for (const k in Lib) and Object.keys(Lib) are still NOT enumerable — namespace imports don't materialize a real object with own properties. Tracked separately if needed.

  2. added a commit that references this issue on May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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