Repository navigation
Inherited method of a capture-bearing class expression sees undefined captures when called on an instance of a capture-bearing class extends subclass without a constructor #10486
Copy link
Copy link
Closed
Labels
package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. Take a class expression declared inside a function whose methods use enclosing locals, and a subclass expression (class extends Base) with its own captures and no explicit constructor. Constructing the subclass never supplies the base class's captured values, so every inherited base method reads undefined for them. This breaks ts.transpileModule with module: CommonJS.
Reproduction
main.ts (no dependencies):
function outer() {
const baseCap = "base-capture";
const subCap = "sub-capture";
const Base = class { m() { return baseCap; } };
const Sub = class extends Base { n() { return subCap; } };
console.log("minimal", new Base().m(), new Sub().m(), new Sub().n());
}
outer();
// typescript.js IdentifierNameMap / IdentifierNameMultiMap shape (lib/typescript.js:96081, :96140)
((module) => {
function idText(n) { return n.text; }
function unorderedRemoveItem(arr, v) { const i = arr.indexOf(v); if (i >= 0) arr.splice(i, 1); }
var NameMap = class _NameMap {
constructor() { this._map = new Map(); }
get(key) { return this._map.get(_NameMap.toKey(key)); }
set(key, value) { this._map.set(_NameMap.toKey(key), value); return this; }
static toKey(name) { return idText(name); }
};
var MultiMap = class extends NameMap {
add(key, value) { let values = this.get(key); if (values) values.push(value); else this.set(key, values = [value]); return values; }
remove(key, value) { const values = this.get(key); if (values) unorderedRemoveItem(values, value); }
};
try {
const m = new MultiMap();
m.add({ text: "x" }, 1);
console.log("ts-shape", JSON.stringify(m.get({ text: "x" })));
} catch (e) {
console.log("ts-shape THREW", e.message);
}
})({});node main.ts
perry compile main.ts -o out && ./out # auto-optimize (default)
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.ts -o out && ./out # identical outputExpected (Node 26.5.1)
minimal base-capture base-capture sub-capture
ts-shape [1]
Actual (Perry)
Identical in both modes:
minimal base-capture undefined sub-capture
ts-shape THREW Cannot convert undefined or null to object
Impact
- typescript 5.8.2 (
perry.compilePackages: ["typescript"]):ts.transpileModule("export const x = 1;\n", { compilerOptions: { module: ts.ModuleKind.CommonJS } })throwsTypeError: Cannot convert undefined or null to object. Node prints the CommonJS output ("use strict"; Object.defineProperty(exports, "__esModule", …); exports.x = void 0; exports.x = 1;). Every ESNext/JSX/decorator option set tested in the audit matched Node. The CJS path fails because of this chain:transformModule(lib/typescript.js:113301) callscollectExternalModuleInfo(:95912).- That builds
exportSpecifiers = new IdentifierNameMultiMap()(:95916). IdentifierNameMultiMap(:96140) isclass extends IdentifierNameMapwith no constructor, and itsremovecapturesunorderedRemoveItem.- The inherited
IdentifierNameMap.get/set/has(:96081) read the captured inner class binding_IdentifierNameMapand the helpersidText,isGeneratedIdentifier, and others. All of them come backundefinedon the subclass instance. - AMD/UMD (same
transformModule) and System (transformSystemModule,:115299, samecollectExternalModuleInfo) are likely affected too (inferred, not run).
- Package-level confirmation (no-auto): I made one edit to
node_modules/typescript/lib/typescript.js, insertingconstructor() { super(); }as the first member ofvar IdentifierNameMultiMap = class extends IdentifierNameMap {(:96140). After that, the same CJStranspileModulecall prints output identical to Node. The source compile takes about 12 min and about 12 GB RSS. - The shape is common in esbuild/tsc-bundled libraries, where every class becomes
var X = class _X {…}inside a module-wrapper function and helpers are captured locals.
Notes
- Variants (all no-auto; the minimal case, the getter/static variant and the captured-helper variant were also run in auto mode, with identical results):
- Fails with base class expression + subclass declaration (
class Sub extends Base {}whereBaseis a class expression). - Fails with two captured base locals (both
undefined). - Fails when the base class has an explicit constructor but the subclass does not.
- Fails more widely when an arrow factory returns the pair and they are constructed outside it: then even
new Base().m()loses the factory-local capture. Escaped class expressions without a subclass work. - Wrong answers for other access forms on a
Subinstance: a getter givesundefined,Base.prototype.m.call(subInstance)givesundefined, and an inherited staticSub.s()returns the subclass's capture (sub-capture) instead of the base's. - A captured helper called from an inherited method throws
value is not a function.
- Fails with base class expression + subclass declaration (
- Works:
- base class declaration + subclass expression;
- a subclass that captures nothing;
- a subclass that captures the same local as the base;
- an explicit
constructor() { super(); }in the subclass (enough on its own); - class expressions without inheritance.
- HIR (
--print-hir) for the minimal case:[6] Let { id: 6, name: "Base", … init: Some(Sequence([LocalSet(5, ClassExprFresh { template: "Base", … captured_args: [LocalGet(0)] }), LocalGet(5)])) } [7] Let { id: 12, name: "Sub", … RegisterClassParentDynamic { class_name: "Sub", parent_expr: LocalGet(6) }, … ClassExprFresh { template: "Sub", … captured_args: [LocalGet(1)] } … } [8] … New { class_name: "Sub", args: [LocalGet(1)], …, cap_args_appended: 1 } … property: "m" Classes: Base (fields: 1, methods: 1, constructor: true), Sub (fields: 1, methods: 1, constructor: true)new Sub()appends onlySub's own capture (subCap, id 1).Base's capture (baseCap, id 0) is never passed, so the base part's synthesized capture field stays unset when the subclass constructor is synthesized. - Suspected cause:
- The append comes from
ctx.lookup_class_captures(&lookup_name)incrates/perry-hir/src/lower/expr_new.rs:1713-1734(andlower/expr_new/non_ident.rs:196), which does not include the parent chain's captures. lower_decl/class_captures.rs:808-840(append_new_args_expr) has the same per-class scope.- An explicit subclass constructor works, presumably because the lowered
super()forwards the parent's captures (inferred).
- The append comes from
- Related: A function-local
varwritten from inside a class constructor/static method is not updated (class captures are by value), breaking emscripten'sFSinode counter #10489, also a class-capture bug in the same machinery (by-value captures keyed by class template name). Different trigger, different symptom.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
Activity
proggeramlug commentedon Sep 20, 2026
Fixed on
main(verified on v0.5.1617). Ran this issue's full reproducer verbatim — both the minimal case and thetypescript.jsIdentifierNameMultiMapshape it was reduced from:byte-identical to node. The claim was
undefinedfor every inherited base capture. Most likely closed by #10628 / #10615.