Build JavaScript default-export objects that hold class values

`export default { parse, stringify, DateType, ParseError }` failed at
module initialization: the literal's width check against the default
binding's type found class members missing.

- A class name in a shorthand property (`{ ParseError }`) now evaluates
  to the class object, exactly like the long form `{ ParseError:
  ParseError }`; it had no lowering, so the JavaScript literal dropped
  the member.
- A class the program could not lower (one extending Date) is still
  dropped from the literal, its diagnostic deferred to the reads that
  reach it. The default binding of a JavaScript object literal now
  registers its type without such members, so the module initializes
  and the named exports work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-20 13:45:28 -07:00
co-authored by Claude Opus 5.5
parent 139542bcf1
commit d1e39f3d8c
8 changed files with 105 additions and 0 deletions
@@ -3193,6 +3193,22 @@ export function lowerShorthandValue(
`generic functions as values (call '${propName.text}' directly)`,
);
}
// A program class name (`{ parse, TomlError }`, an imported class
// included): the class object, exactly the long form `{ C: C }`.
// Generic classes keep the identifier path's contextual pinning.
const classInfo = lowerer.classBySymbol.get(resolved);
if (classInfo && !classInfo.generic) {
const decoratedGlobal = classInfo.classDecorators?.valueGlobalId;
if (decoratedGlobal !== undefined) {
return {
kind: "varRef",
localId: decoratedGlobal,
type: { kind: "classval", className: classInfo.def.name },
loc,
};
}
return lowerer.classValueRef(classInfo, propName);
}
}
lowerer.rejectUnresolvedSymbol(
symbol
@@ -1229,6 +1229,36 @@ function jsDynHoldableInitializer(lowerer: Lowerer, init: ts.Expression | undefi
return false;
}
/** A JavaScript default-export object literal holding a class the program
* could not lower (`export default { parse, TomlDate }` where TomlDate
* extends an unsupported base): the literal narrows that member away, its
* diagnostics deferred to the reads that reach it, so the binding's type
* drops it too. Otherwise the module's own initialization would fail the
* width check against members no build can supply. */
function withoutRejectedClassMembers(
lowerer: Lowerer,
expression: ts.Expression,
type: IrType,
): IrType {
let literal = expression;
while (ts.isParenthesizedExpression(literal)) literal = literal.expression;
if (!ts.isObjectLiteralExpression(literal) || type.kind !== "record") return type;
const shape = lowerer.shapes.get(type.shapeId);
if (!shape || shape.tuple) return type;
const rejected = (t: IrType): boolean =>
t.kind === "classval" &&
!lowerer.classes.has(t.className) &&
lowerer.deferredClassByName.has(t.className);
if (!shape.fields.some((f) => rejected(f.type))) return type;
const kept = shape.fields.filter((f) => !rejected(f.type));
const shapeId = lowerer.shapes.intern(
kept.map((f) => ({ name: f.name, type: f.type })),
false,
shape.indexValue,
);
return { kind: "record", shapeId };
}
export function collectGlobals(
lowerer: Lowerer,
sf: ts.SourceFile,
@@ -1331,6 +1361,7 @@ export function collectGlobals(
type = unitOnlyUnion(lowerer.unions);
}
if (type.kind === "void") lowerer.badType(stmt.expression, tsType);
if (isJsSourceFile(sf)) type = withoutRejectedClassMembers(lowerer, stmt.expression, type);
const g: IrGlobal = { id: `%g.${tag}default`, name: "default", type, mutable: false };
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
@@ -0,0 +1,9 @@
{
"order": [
"<repo>/tests/corpus/6494-default-export-class-members/lib/date.js",
"<repo>/tests/corpus/6494-default-export-class-members/lib/error.js",
"<repo>/tests/corpus/6494-default-export-class-members/lib/index.js",
"<repo>/tests/corpus/6494-default-export-class-members/main.ts"
],
"diags": []
}
@@ -0,0 +1,8 @@
// A class whose base scriptc cannot extend yet: the default export object
// below narrows this member away, and only reads of it would fence.
export class StampDate extends Date {
constructor(text) {
super(text);
this.label = "stamp:" + text;
}
}
@@ -0,0 +1,7 @@
export class ParseError extends Error {
constructor(message, line) {
super(message);
this.name = "ParseError";
this.line = line;
}
}
@@ -0,0 +1,14 @@
import { StampDate } from "./date.js";
import { ParseError } from "./error.js";
function parse(text) {
if (text === "") throw new ParseError("empty document", 1);
const [key, value] = text.split("=").map((part) => part.trim());
return { key, value };
}
function stringify(value) {
return JSON.stringify(value);
}
// A default export object of functions and class values (the shorthand
// spelling of `{ ParseError: ParseError }`).
export default { parse, stringify, StampDate, ParseError };
export { parse, stringify, StampDate, ParseError };
@@ -0,0 +1,15 @@
// A JavaScript module's default export object holding functions and class
// values builds at module initialization, and its members work through
// both the default object and the named exports.
import lib, { parse, ParseError } from "./lib/index.js";
console.log(JSON.stringify(parse("a = 1")));
console.log(lib.stringify(lib.parse("b = 2")));
try {
lib.parse("");
} catch (e) {
if (e instanceof ParseError) console.log(e.name, e.message, e.line);
}
const Ctor = lib.ParseError;
const made = new Ctor("built through the default object", 7);
console.log(made instanceof ParseError, made.message, made.line, Ctor === ParseError);
@@ -0,0 +1,5 @@
{
"name": "default-export-class-members-corpus",
"private": true,
"type": "module"
}