Map generic alias casts inside generic bodies with the instance's arguments (#2227)

This commit is contained in:
Chris Tate
2026-10-25 15:32:14 -05:00
committed by GitHub
6 changed files with 160 additions and 0 deletions
@@ -1040,6 +1040,14 @@ function unifySignatureBindings(
dAliasArgs.length &&
iAliasArgs.length === dAliasArgs.length
) {
if (tsBindings && declared !== inst) {
let entries = lowerer.boundAliasInstances.get(declared);
if (!entries) lowerer.boundAliasInstances.set(declared, (entries = []));
// A body forwarding its own alias (`inner(g)` returning `Catalog<G,
// "x">` inside an instance) resolves through the enclosing one.
if (!entries.some((entry) => entry.tsBindings === tsBindings))
entries.push({ tsBindings, instance: lowerer.boundAliasTsResolver(inst) ?? inst });
}
dAliasArgs.forEach((da, i) => {
const ia = iAliasArgs[i];
if (ia) unify(da, ia, depth + 1);
@@ -2233,6 +2233,19 @@ export class Lowerer {
* the body needs — indexed accesses (`T[K]` needs K's literal key) and
* keyed record reads (`o[k]` where k's type is a literal-bound K). */
typeParamTsBindings: Map<ts.Symbol, ts.Type> | null = null;
/** Generic alias references of call-keyed instances' declared signatures
* (`Catalog<G, P>`), each with the same alias as every call's resolved
* signature instantiates it (`Catalog<{ g1: … }, "acme">`), together with
* that instance's ts-level bindings map (typeParamTsBindings), which every
* scope that lowers the instance installs. The checker keeps the alias
* symbolic inside the generic body and offers no way to instantiate it
* with the bound arguments, but alias instantiations are interned: the
* body's `x as Catalog<G, P>` is the very type the signature declares, so
* the pair recorded during unification is that re-instantiation. */
readonly boundAliasInstances = new Map<
ts.Type,
{ tsBindings: Map<ts.Symbol, ts.Type>; instance: ts.Type }[]
>();
/** Non-null while an IMPLICIT-ANY instance body lowers (npm-static JS;
* see generic-functions.ts): bound param symbol →
* the call site's checker type, consulted by typeOf for identifier
@@ -3797,6 +3810,7 @@ export class Lowerer {
classNamer: (decl) => this.classNamer(decl),
resolveTypeParam: this.typeParamResolver,
resolveTypeParamTs: this.typeParamTsResolver,
resolveBoundAliasTs: this.boundAliasTsResolver,
isErasing: () => this.erasingDepth > 0,
genericClassInstance: (decl, ref, mapArgument) =>
this.genericClassInstanceType(decl, ref, mapArgument),
@@ -9813,6 +9827,19 @@ export class Lowerer {
return (sym && this.typeParamTsBindings.get(sym)) ?? null;
};
/** The concrete instantiation of a generic alias reference (`Catalog<G,
* P>`) the current call-keyed instance's signature declares
* (boundAliasInstances), or null when its signature never instantiated
* that reference. Inert outside generic function instantiation. */
readonly boundAliasTsResolver = (t: ts.Type): ts.Type | null => {
if (!this.typeParamTsBindings || t.getAliasTypeArguments().length === 0) return null;
const tsBindings = this.typeParamTsBindings;
return (
this.boundAliasInstances.get(t)?.find((entry) => entry.tsBindings === tsBindings)?.instance ??
null
);
};
irTypeOf(node: ts.Node): IrType {
const t = this.typeOf(node);
// JS inference can leave mutable fields, accessor parameters and
@@ -1270,6 +1270,11 @@ export interface TypeMapperCtx {
* only where literal identity matters — indexed accesses (`T[K]`) inside
* generic bodies whose K is bound to a literal key. */
resolveTypeParamTs?: TypeParamTsResolver;
/** A generic alias reference inside a generic body (`Catalog<G, P>`)
* re-instantiated with the instance's bound type arguments: the concrete
* checker type its signature instantiates, or null. Consulted only when
* the symbolic form does not map. */
resolveBoundAliasTs?: TypeParamTsResolver;
/** GENERIC program classes (monomorphization by flow): the mapping of a
* concrete instantiation reference (`Box<number>`) — the Lowerer
* registers/reuses the instantiation (`Box%0`) and answers its object
@@ -1834,6 +1839,16 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (!topLevel || !ts.isUnreadableConstituentsPanic(e)) throw e;
mapped = null;
}
// A generic alias the body spells over its own type parameters (`x as
// Catalog<G, P>`) stays symbolic in the checker; when that form does not
// map, the instance's concrete instantiation of the same alias does.
if (mapped === null && ctx.resolveBoundAliasTs) {
const concrete = ctx.resolveBoundAliasTs(type);
if (concrete !== null && concrete !== type) {
mapped = mapType(concrete, ctx);
if (mapped !== null) contextResolutions++;
}
}
if (
mapped !== null &&
memoKey !== null &&
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-alias-cast-instances.ts"
],
"diags": []
}
@@ -0,0 +1,103 @@
// Casts to a generic type alias spelled over the enclosing function's own
// type parameters (`x as Catalog<G, P>`). The checker keeps such an alias
// symbolic inside the generic body; each instance maps it with its own bound
// type arguments.
type Groups = Record<string, Record<string, object>>;
type EntryKey<G extends Groups> = { [A in keyof G]: keyof G[A] }[keyof G] & string;
type KeyOfKind<G extends Groups, K extends string> = Extract<EntryKey<G>, `${K}:${string}`>;
type EntryId<K extends string> = K extends `${string}:${infer I}` ? I : never;
type Catalog<G extends Groups, P extends string, K extends string> = {
[Key in KeyOfKind<G, K> as EntryId<Key>]: { id: EntryId<Key>; label: string; owner: P };
};
function flatten(groups: Groups, kind: string): Record<string, object> {
return Object.fromEntries(
Object.values(groups)
.flatMap((entries) => Object.entries(entries))
.filter(([key]) => key.startsWith(kind + ":"))
.map(([, entry]) => [(entry as { id: string }).id, entry]),
);
}
function toolCatalog<const P extends string, const G extends Groups>(
_owner: P,
groups: G,
): Catalog<G, P, "tool"> {
return flatten(groups, "tool") as Catalog<G, P, "tool">;
}
function partCatalog<const P extends string, const G extends Groups>(
_owner: P,
groups: G,
): Catalog<G, P, "part"> {
return flatten(groups, "part") as Catalog<G, P, "part">;
}
const workshop = {
bench: {
"tool:saw": { id: "saw", label: "Hand saw", owner: "acme" },
"part:bolt": { id: "bolt", label: "Hex bolt", owner: "acme" },
},
shelf: {
"tool:drill": { id: "drill", label: "Cordless drill", owner: "acme" },
},
};
const tools = toolCatalog("acme", workshop);
console.log(tools.saw.label, tools.drill.id, tools.drill.owner);
console.log(Object.keys(tools).join(","), JSON.stringify(tools.saw));
const parts = partCatalog("acme", workshop);
console.log(parts.bolt.label, Object.keys(parts).length);
// A second instantiation with different groups gets its own layout.
const garden = {
shed: {
"tool:rake": { id: "rake", label: "Leaf rake", owner: "green" },
"tool:hoe": { id: "hoe", label: "Garden hoe", owner: "green" },
},
};
const gardenTools = toolCatalog("green", garden);
console.log(gardenTools.rake.label, gardenTools.hoe.label, Object.keys(gardenTools).join(","));
// No entry of the kind: the alias resolves to the empty record.
const gardenParts = partCatalog("green", garden);
console.log(Object.keys(gardenParts).length, JSON.stringify(gardenParts));
// A homomorphic mapped alias over the bound type, cast inside the body.
type Boxed<T> = { [K in keyof T]: { value: T[K] } };
function boxAll<T extends Record<string, number>>(values: T): Boxed<T> {
const out: Record<string, object> = {};
for (const key of Object.keys(values)) out[key] = { value: values[key] };
return out as Boxed<T>;
}
const boxed = boxAll({ width: 3, height: 4 });
console.log(boxed.width.value * boxed.height.value);
// The alias nested in an array type of the signature.
function boxEach<T extends Record<string, number>>(rows: T[]): Boxed<T>[] {
return rows.map((row) => {
const out: Record<string, object> = {};
for (const key of Object.keys(row)) out[key] = { value: row[key] };
return out as Boxed<T>;
});
}
const rows = boxEach([
{ x: 1, y: 2 },
{ x: 5, y: 8 },
]);
console.log(rows.map((r) => r.x.value + r.y.value).join(" "));
// A generic body forwarding its parameters to another generic cast.
function ownedTools<const G extends Groups>(groups: G): Catalog<G, "shared", "tool"> {
return toolCatalog("shared", groups);
}
const shared = ownedTools({ rack: { "tool:clamp": { id: "clamp", label: "C-clamp", owner: "shared" } } });
console.log(shared.clamp.label, shared.clamp.owner);
@@ -0,0 +1 @@
{ "plain": 3000, "san": 3600 }