Map generic alias casts inside generic bodies with the instance's arguments

A cast to a generic type alias spelled over the enclosing function's own
type parameters (`return x as Catalog<G, P>`) stays symbolic in the
checker inside the body, so a mapped type with key remapping, a template
key filter or a conditional member refused with SC2001 in every instance.

The checker interns alias instantiations, so the body's `Catalog<G, P>` is
the same type the signature declares. Binding unification now records, per
call-keyed instance, each declared generic alias reference together with
the alias the call's resolved signature instantiates. When the symbolic
form does not map, mapType maps that concrete instantiation instead. A body
forwarding its own alias to another generic resolves through the enclosing
instance. Types that already mapped are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 09:22:06 -07:00
co-authored by Claude Opus 5.5
parent 8a22e0f515
commit 541b05ddea
6 changed files with 163 additions and 0 deletions
@@ -921,6 +921,27 @@ function bindDefaultTypeParams(
});
}
/** Generic alias references of a call-keyed instance's declared signature
* (`Catalog<G, P>`), each paired with the same alias as the call's resolved
* signature instantiates it (`Catalog<{ g1: … }, "acme">`). 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.
* Keyed by the instance's ts-level bindings map, which every scope that
* lowers the instance installs (typeParamTsBindings). */
const boundAliasInstances = new WeakMap<Map<ts.Symbol, ts.Type>, Map<ts.Type, ts.Type>>();
/** The concrete instantiation of a generic alias reference inside the
* instance whose ts-level bindings are `tsBindings` (boundAliasInstances),
* or null when its signature never instantiated that reference. */
export function boundAliasInstance(
tsBindings: Map<ts.Symbol, ts.Type>,
type: ts.Type,
): ts.Type | null {
return boundAliasInstances.get(tsBindings)?.get(type) ?? null;
}
/** The structural half of binding inference: unify the DECLARED signature
* (whose types mention the type parameters) against a TARGET signature
* with the substitution applied — a call's resolved signature, or the
@@ -1040,6 +1061,13 @@ function unifySignatureBindings(
dAliasArgs.length &&
iAliasArgs.length === dAliasArgs.length
) {
if (tsBindings && declared !== inst) {
let pairs = boundAliasInstances.get(tsBindings);
if (!pairs) boundAliasInstances.set(tsBindings, (pairs = new Map()));
// A body forwarding its own alias (`inner(g)` returning `Catalog<G,
// "x">` inside an instance) resolves through the enclosing one.
if (!pairs.has(declared)) pairs.set(declared, lowerer.boundAliasTsResolver(inst) ?? inst);
}
dAliasArgs.forEach((da, i) => {
const ia = iAliasArgs[i];
if (ia) unify(da, ia, depth + 1);
@@ -456,6 +456,7 @@ import {
inferTypeParamBindings,
lowerGenericInstance,
genericLocalClassBinding,
boundAliasInstance,
} from "./generic-functions.js";
import { bindingNeverReassigned } from "./binding-analysis.js";
import type { InstantiationPath } from "./instantiation-path.js";
@@ -3792,6 +3793,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),
@@ -9550,6 +9552,14 @@ 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
* (boundAliasInstance). Inert outside generic function instantiation. */
readonly boundAliasTsResolver = (t: ts.Type): ts.Type | null => {
if (!this.typeParamTsBindings || t.getAliasTypeArguments().length === 0) return null;
return boundAliasInstance(this.typeParamTsBindings, t);
};
irTypeOf(node: ts.Node): IrType {
const t = this.typeOf(node);
// JS inference can leave mutable fields, accessor parameters and
@@ -1266,6 +1266,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
@@ -1830,6 +1835,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 }