Reflect the prototype of function-local classes without prototype members (#1958)

This commit is contained in:
Chris Tate
2026-10-23 01:46:06 -05:00
committed by GitHub
8 changed files with 111 additions and 1 deletions
@@ -289,6 +289,16 @@ export function hasClassPrototypeData(info: ClassInfo): boolean {
);
}
/** Methods and accessors that live on the class's prototype. */
function declaresPrototypeMembers(decl: ts.ClassLikeDeclaration): boolean {
return decl.members.nodes.some(
(member) =>
(ts.isMethodDeclaration(member) || ts.isAccessor(member)) &&
!ts.isPrivateIdentifier(member.name) &&
!ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword),
);
}
/** Populate the shared prototype view before exposing it to reflection.
* Methods and accessors are non-enumerable and retain declaration identity. */
export function reflectClassPrototype(
@@ -318,7 +328,10 @@ export function reflectClassPrototype(
// Local JavaScript factories initialize captured prototype members on
// their constructor value, so the shared runtime view is already complete.
if (info.localClass && info.runtimePrototypeMembers) return prototype;
if (info.localClass)
// A function-local class declaring no prototype members (zod 4's
// `class Definition extends Parent {}`) has nothing to install: its
// shared runtime view is complete once its base's is.
if (info.localClass && declaresPrototypeMembers(info.decl))
return refuse("prototype reflection on a function-local class is not supported yet");
if (info.base && !info.base.builtinError) {
reflectClassPrototype(
@@ -544,6 +557,15 @@ export function lowerClassPrototypeData(
if (name === "constructor") {
return reflectClassPrototype(lowerer, info, locOf(expr), classValueRef(lowerer, info, expr));
}
// The whole prototype of a function-local class: the shared view
// Object.getPrototypeOf answers for its instances.
if (name === null && info.localClass) {
const receiver = lowerer.lowerExprExpecting(expr.expression, {
kind: "classval",
className: info.def.name,
});
return reflectClassPrototype(lowerer, info, locOf(expr), receiver);
}
if (name === null || (isCompiledPrototypeMember(lowerer, info, name) && !method)) {
lowerer.unsupported(
"SC1090",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7613-local-class-prototype-reflection.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7615-local-class-prototype-reflection-ts.ts"
],
"diags": []
}
+5
View File
@@ -4416,6 +4416,11 @@ ScrDyn *scr_dyn_class_prototype(ScrDyn *value, ScrDyn *base) {
* the class object and no registry keeps it alive, so evaluating a
* function-local class again releases the previous one's prototype. */
if (cls->name && cls->name->len) data->v.obj.class_name = cls->name->data;
/* Like every class prototype, it starts with its own non-enumerable
* `constructor`: the class value. The cycle it closes through the class
* object is one the collector traces. */
scr_dyn_obj_set(data, "constructor", 11, scr_dyn_retain(value));
data->v.obj.entries[data->v.obj.len - 1].enumerable = false;
cls->prototype_data = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v);
scr_box_set_ref(cls->prototype_data, data);
}
@@ -0,0 +1,55 @@
// Prototype reflection on instances of a function-local class without
// methods (zod 4's `$constructor` Definition): the prototype is the class's
// shared prototype object, it chains to the parent's, and members defined
// on it reach every instance.
const installed = new WeakSet();
function factory(name, Parent) {
class Definition extends Parent {}
Object.defineProperty(Definition, "name", { value: name });
return function make(label) {
const inst = new Definition();
inst.label = label;
const proto = Object.getPrototypeOf(inst);
console.log(name, proto === Definition.prototype, proto === Object.getPrototypeOf(new Definition()));
if (!installed.has(proto)) {
installed.add(proto);
Object.defineProperty(proto, "describe", {
configurable: true,
enumerable: false,
get() {
return () => `${name}:${this.label}`;
},
});
console.log("installed", name);
}
return inst;
};
}
const makeError = factory("Failure", Error);
const makePlain = factory("Thing", Object);
const a = makeError("a");
const b = makeError("b");
const c = makePlain("c");
console.log(a.describe(), b.describe(), c.describe());
console.log(a instanceof Error, c instanceof Error, Object.keys(a).join(), Object.keys(c).join());
console.log(Object.getPrototypeOf(a) === Object.getPrototypeOf(b), Object.getPrototypeOf(a) === Object.getPrototypeOf(c));
console.log(typeof Object.getPrototypeOf(c).describe, "describe" in a, Object.hasOwn(a, "describe"));
// The prototype has its own non-enumerable `constructor`, the class value
// of that evaluation, as every class prototype does.
function local(Parent) {
class Definition extends Parent {}
const proto = Object.getPrototypeOf(new Definition());
const descriptor = Object.getOwnPropertyDescriptor(proto, "constructor");
console.log(
proto.constructor === Definition,
Object.hasOwn(proto, "constructor"),
JSON.stringify(Object.getOwnPropertyNames(proto)),
descriptor.value === Definition,
descriptor.writable,
descriptor.enumerable,
descriptor.configurable,
);
return Definition;
}
console.log(local(Object) !== local(Error));
@@ -0,0 +1,14 @@
// Prototype reflection on an instance of a TypeScript function-local class
// without methods: one shared prototype per class evaluation.
function make(label: string): { label: string } {
class Box {
label = "";
}
const box = new Box();
box.label = label;
return box;
}
const a = make("a");
const b = make("b");
const pa = Object.getPrototypeOf(a);
console.log(pa === Object.getPrototypeOf(a), pa === Object.getPrototypeOf(b), typeof pa, Object.keys(a).join());
@@ -0,0 +1 @@
{ "plain": 1967, "san": 2510 }
@@ -0,0 +1 @@
{ "plain": 1466, "san": 2143 }