Dispatch new.target over generic subclasses and own static names

A constructor's new.target dispatch skipped generic class families, so a
generic subclass instantiated only in a body lowered later answered with
the base's name, and `new.target === Base` held for its instances. A
family now has its own arm, tested by its instanceof interval, which holds
every instantiation: `new.target.name` reads the family's name and
comparisons with a program class are false. Under an instantiation, the
generic subclasses of its family are candidates too. `new.target` as a
value refuses generic subclasses, which have no common class value.

`new.target.name` reads a class's own static `name` field, which replaces
its name (a subclass has its own name); a static method `name` or a
non-string field is refused.
This commit is contained in:
Chris Tate
2026-10-22 21:18:48 -05:00
parent ac940ed936
commit 6115135d96
7 changed files with 151 additions and 12 deletions
@@ -43,7 +43,9 @@ function bindingOf(node: ts.Node): Binding {
interface Dispatch {
info: ClassInfo;
/** The constructor's class and its descendants, deepest first. */
/** The constructor's descendants, deepest first, then its class. A
* generic family stands for its instantiations, also those that later
* bodies instantiate: its interval holds them all. */
classes: ClassInfo[];
self: IrExpr | null;
}
@@ -69,11 +71,23 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null {
);
return null;
}
const classes = [info];
// An instantiation's subclasses can also be instantiations of generic
// subclasses of its family (`class G<T> extends Box<T>` under Box<number>).
const family = info.genericInstance?.family;
const descendants: ClassInfo[] = [];
for (const other of lowerer.classes.values()) {
if (other === info || other.generic) continue;
if (lowerer.isSubclassOf(other.def.name, info.def.name)) classes.push(other);
if (other === info) continue;
if (
lowerer.isSubclassOf(other.def.name, info.def.name) ||
(family !== undefined &&
other.generic !== undefined &&
other !== family &&
lowerer.isSubclassOf(other.def.name, family.def.name))
)
descendants.push(other);
}
descendants.sort((a, b) => depth(b) - depth(a));
const classes = [...descendants, info];
for (const c of classes) {
if (c.localClass) {
lowerer.unsupported(
@@ -84,7 +98,6 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null {
return null;
}
}
classes.sort((a, b) => depth(b) - depth(a));
let self: IrExpr | null = null;
if (classes.length > 1) {
const thisLocal = lowerer.resolveThis(expr);
@@ -140,6 +153,13 @@ export function lowerNewTarget(lowerer: Lowerer, expr: ts.MetaProperty): IrExpr
const dispatch = dispatchOf(lowerer, expr)!;
const type: IrType = { kind: "classval", className: dispatch.info.def.name };
for (const c of dispatch.classes) {
if (c.generic) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' as a value where '${c.def.jsName ?? c.def.name}' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead)`,
);
}
if (c !== dispatch.info && !lowerer.ctorAbiEquals(c, dispatch.info)) {
lowerer.unsupported(
"SC1090",
@@ -166,13 +186,37 @@ export function lowerNewTargetName(
const dispatch = dispatchOf(lowerer, target);
if (!dispatch) return null;
const loc = locOf(access);
return select(dispatch, STRING, loc, (c) => ({
kind: "libCall",
fn: "class.name",
args: [classValueRef(lowerer, c, target)],
type: STRING,
loc,
}));
return select(dispatch, STRING, loc, (c) => {
// An own static `name` field replaces the class's name; a subclass has
// its own name. An instantiation's statics are its family's.
const owner = c.genericInstance?.family ?? c;
const field = owner.staticFields.find((f) => f.name === "name");
if (field !== undefined) {
if (field.type.kind !== "string")
lowerer.unsupported(
"SC1090",
access,
`'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static 'name' that is not a string`,
);
return { kind: "varRef", localId: field.globalId, type: field.type, loc };
}
if (owner.staticMethods?.has("name") || owner.genericStatics?.has("name"))
lowerer.unsupported(
"SC1090",
access,
`'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static method 'name'`,
);
// A generic family's instantiations share its name.
if (c.generic)
return { kind: "strLit", value: c.def.jsName ?? c.generic.baseName, type: STRING, loc };
return {
kind: "libCall",
fn: "class.name",
args: [classValueRef(lowerer, c, target)],
type: STRING,
loc,
};
});
}
/** `new.target === C` (and `!==`, `==`, `!=`, either operand order) with a
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7364-new-target-late-instantiations.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-value-generic-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,71 @@
// new.target in a constructor whose subclasses are generic classes
// instantiated only in bodies lowered later, and classes whose own static
// `name` field replaces their name.
class Plain {
readonly label: string;
readonly plain: boolean;
constructor() {
this.label = new.target.name;
this.plain = new.target === Plain;
}
}
class Sub<T> extends Plain {
readonly v: T;
constructor(v: T) {
super();
this.v = v;
}
}
class Box<T> {
readonly label: string;
readonly exact: boolean;
readonly value: T;
constructor(value: T) {
this.value = value;
this.label = new.target.name;
this.exact = new.target === Leaf;
}
}
class Tagged<T> extends Box<T> {}
class Leaf extends Tagged<number> {}
class Named {
static name = "custom";
readonly label: string;
constructor() {
this.label = new.target.name;
}
}
class Child extends Named {}
class Other extends Named {
static name = "other";
}
class Family<T> {
static name = "family";
readonly label: string;
readonly v: T;
constructor(v: T) {
this.label = new.target.name;
this.v = v;
}
}
function later(): void {
const n = new Sub<number>(1);
const p = new Plain();
console.log(n.label, new Sub<string>("s").label, p.label, n.plain, p.plain);
const t = new Tagged<number>(1);
const s = new Tagged<string>("a");
const b = new Box<number>(2);
const l = new Leaf(3);
console.log(t.label, s.label, b.label, l.label, t.exact, l.exact, b.exact);
console.log(new Family<boolean>(true).label);
}
// The bases construct here first; their subclasses only in later().
console.log(new Plain().label, new Box<boolean>(false).label);
console.log(new Named().label, new Child().label, new Other().label);
Named.name = "changed";
console.log(new Named().label, new Child().label, new Other().label);
later();
@@ -0,0 +1,5 @@
// new.target as a class value where a subclass is generic: its
// instantiations have no common class value.
class A { readonly t: typeof A; constructor() { this.t = new.target; } }
class B<T> extends A { readonly v: T | undefined = undefined; }
console.log(new B<number>().t.name);
@@ -0,0 +1,6 @@
new-target-value-generic-subclass.ts:3:58 - error SC1090: 'new.target' as a value where 'B' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead) is not supported yet
2 | // instantiations have no common class value.
3 | class A { readonly t: typeof A; constructor() { this.t = new.target; } }
| ^~~~~~~~~~
4 | class B<T> extends A { readonly v: T | undefined = undefined; }
@@ -0,0 +1 @@
{ "plain": 4300, "san": 17500 }