Close recursive record knots through erased generic method members (#2246)

This commit is contained in:
Chris Tate
2026-10-25 15:07:46 -05:00
committed by GitHub
4 changed files with 84 additions and 3 deletions
+19 -3
View File
@@ -1255,6 +1255,10 @@ export interface TypeMapperCtx {
* (erasedGenericMemberType): the signature's own type parameters
* resolve to `unknown` instead of refusing the mapping. */
erasingSignature?: ts.Signature;
/** Set with erasingSignature: the signature's own type parameters, and
* whether the erasure consulted an enclosing binding. An erasure that
* resolved only its own parameters answers the same in every context. */
erasedMember?: { own: ReadonlySet<ts.Type>; outerConsulted: boolean };
/** True while an erased generic body lowers (its type parameters are
* bound to `unknown`). */
isErasing?: () => boolean;
@@ -1835,6 +1839,7 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
memoKey !== null &&
topLevel &&
contextResolutions === sensitivityAtEntry &&
ctx.erasingSignature === undefined &&
(ctx.canMemoizeType?.() ?? true)
) {
ctx.typeMemo?.set(memoKey, mapped);
@@ -1991,7 +1996,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
!ERASURE_KEPT_CONTAINERS.has(ts.symbolNameOf(type.getSymbol()) ?? "") &&
mentionsErasedTypeParam(type, ctx, 0)
) {
contextResolutions++;
if (!ctx.erasedMember || ctx.erasedMember.outerConsulted) contextResolutions++;
return DYN;
}
// Readonly<C> over a program class (Object.freeze's result type) is the
@@ -2001,7 +2006,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) {
const bound = resolveTypeParam(type);
if (bound) {
contextResolutions++;
if (!ctx.erasedMember?.own.has(type)) contextResolutions++;
return bound;
}
}
@@ -7335,10 +7340,21 @@ export function erasedGenericMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType
const sig = sigs[0]!;
const own = new Set<ts.Type>(sig.getTypeParameters());
const outer = ctx.resolveTypeParam;
// Resolving only the member's own parameters does not make the erasure
// context-sensitive, so a recursive record holding a generic method
// (`interface Sub extends Base { send<T>(m: Msg<T>): void }` reached
// from Base) still closes its knot. Consulting an enclosing binding does.
const erasedMember = { own, outerConsulted: false };
const erased = mapType(t, {
...ctx,
erasingSignature: sig,
resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)),
erasedMember,
resolveTypeParam: (param) => {
if (own.has(param)) return DYN;
if (outer === undefined) return null;
erasedMember.outerConsulted = true;
return outer(param);
},
});
return erased?.kind === "func" ? erased : undefined;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/recursive-generic-method-members.ts"
],
"diags": []
}
@@ -0,0 +1,58 @@
// A recursive record that declares a generic method compiles. Erasing the
// method's own type parameters does not depend on any enclosing generic
// binding, so the record still closes its recursive knot: a parent
// interface whose child is a subinterface declaring a generic method, and a
// list node whose generic method sits beside a self-referencing field.
interface Message<T = unknown> {
content: string;
details?: T;
}
interface TreeNode {
name: string;
child?: SendingNode;
}
interface SendingNode extends TreeNode {
send<T = unknown>(message: Message<T>): string;
}
interface ListNode {
value: number;
next: ListNode | null;
map<U>(f: (value: number) => U): string;
}
function describe(node: TreeNode): string {
const inner = node.child;
return inner ? `${node.name} > ${describe(inner)}` : node.name;
}
function leaf(name: string): SendingNode {
return { name, send: () => `${name} got a message` };
}
const tree: SendingNode = {
name: "root",
child: { name: "middle", child: leaf("leaf"), send: () => "middle got a message" },
send: () => "root got a message",
};
console.log(describe(tree));
console.log(tree.send({ content: "hello" }));
const middle = tree.child;
if (middle !== undefined) console.log(middle.send({ content: "hi", details: 42 }));
function cons(value: number, next: ListNode | null): ListNode {
return { value, next, map: () => `node ${value}` };
}
let list: ListNode | null = cons(1, cons(2, cons(3, null)));
const values: number[] = [];
let labels = "";
while (list !== null) {
values.push(list.value);
labels += `[${list.map((v) => v * 2)}]`;
list = list.next;
}
console.log(values.join(","), labels);
@@ -0,0 +1 @@
{ "plain": 4000 }