From 541b05ddea63bf8ddde6b4828769ba4e46886717 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 09:22:06 -0700 Subject: [PATCH] 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`) 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` 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 --- .../frontend/lowering/generic-functions.ts | 28 +++++ .../compiler/src/frontend/lowering/lowerer.ts | 10 ++ packages/compiler/src/frontend/type-mapper.ts | 15 +++ .../generic-alias-cast-instances.ts.json | 6 + tests/corpus/generic-alias-cast-instances.ts | 103 ++++++++++++++++++ .../generic-alias-cast-instances.ts.json | 1 + 6 files changed, 163 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json create mode 100644 tests/corpus/generic-alias-cast-instances.ts create mode 100644 tests/harness/corpus-costs/generic-alias-cast-instances.ts.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 0f9e41723..7d7124027 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -921,6 +921,27 @@ function bindDefaultTypeParams( }); } +/** Generic alias references of a call-keyed instance's declared signature + * (`Catalog`), 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` 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>(); + +/** 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, + 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` 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); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ee7477e43..52f3567fb 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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`) 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 diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 17f9d0d11..5fa361db2 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -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`) + * 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`) — 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`) 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 && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json new file mode 100644 index 000000000..a229ee862 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-alias-cast-instances.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-alias-cast-instances.ts b/tests/corpus/generic-alias-cast-instances.ts new file mode 100644 index 000000000..11a098214 --- /dev/null +++ b/tests/corpus/generic-alias-cast-instances.ts @@ -0,0 +1,103 @@ +// Casts to a generic type alias spelled over the enclosing function's own +// type parameters (`x as Catalog`). The checker keeps such an alias +// symbolic inside the generic body; each instance maps it with its own bound +// type arguments. + +type Groups = Record>; + +type EntryKey = { [A in keyof G]: keyof G[A] }[keyof G] & string; +type KeyOfKind = Extract, `${K}:${string}`>; +type EntryId = K extends `${string}:${infer I}` ? I : never; + +type Catalog = { + [Key in KeyOfKind as EntryId]: { id: EntryId; label: string; owner: P }; +}; + +function flatten(groups: Groups, kind: string): Record { + 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( + _owner: P, + groups: G, +): Catalog { + return flatten(groups, "tool") as Catalog; +} + +function partCatalog( + _owner: P, + groups: G, +): Catalog { + return flatten(groups, "part") as Catalog; +} + +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 = { [K in keyof T]: { value: T[K] } }; + +function boxAll>(values: T): Boxed { + const out: Record = {}; + for (const key of Object.keys(values)) out[key] = { value: values[key] }; + return out as Boxed; +} + +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>(rows: T[]): Boxed[] { + return rows.map((row) => { + const out: Record = {}; + for (const key of Object.keys(row)) out[key] = { value: row[key] }; + return out as Boxed; + }); +} + +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(groups: G): Catalog { + 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); diff --git a/tests/harness/corpus-costs/generic-alias-cast-instances.ts.json b/tests/harness/corpus-costs/generic-alias-cast-instances.ts.json new file mode 100644 index 000000000..8c0e66c3c --- /dev/null +++ b/tests/harness/corpus-costs/generic-alias-cast-instances.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3600 }