From 558fb7c8bab2c969592df5c0ff8224a8d7cc56b6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 14:20:20 -0500 Subject: [PATCH] Bind only conditional-dependent class type arguments as checker types A generic class instance now binds a type parameter to its checker type only when a conditional type in the class depends on it, and that checker type keys the instance. Before, every type argument was bound while only literal arguments of those parameters were in the key, so two instances such as Holder and Holder with a `T[K]` field shared the first instance's layout. The other parameters get no checker binding, so member types that depend on their literal identity stay unresolved, as before. Detection now follows the type aliases and interfaces a member's type refers to, so a conditional wrapped in a union alias, such as `(K extends "required" ? T : T | undefined) | null`, also separates the instances. --- .../src/frontend/lowering/lower-classes.ts | 77 ++++++++++++++----- .../6908-generic-class-literal-conditional.ts | 15 ++++ 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index ae03a748c..3db208f55 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -4616,22 +4616,28 @@ export function genericClassInstanceType( if (bound) tsArgs.push(bound); } } - // A literal argument for a type parameter that a conditional type - // depends on (`K extends "required" ? T : T | undefined`) selects the - // members' types, so it keys its own instance; mapType alone widens it. + // A type parameter that a conditional type in the class depends on + // (`K extends "required" ? T : T | undefined`) is bound to its checker + // type for the members, and that type keys the instance: mapType alone + // widens a literal, so Field and Field would otherwise share one layout. The other parameters get + // no checker binding, so nothing in the members depends on a literal + // the key does not carry. gci.literalSensitive ??= literalSensitiveTypeParams(lowerer, gci); - const literalKey = (i: number): string | null => { - if (!gci.literalSensitive!.has(gci.typeParams[i]!)) return null; - const arg = tsArgs[i]; + const sensitive = (i: number): boolean => + tsArgs.length === gci.typeParams.length && gci.literalSensitive!.has(gci.typeParams[i]!); + const checkerKey = gci.typeParams + .map((_, i) => (sensitive(i) ? `#${tsArgs[i]!.id}` : "*")) + .join(","); + const key = + mapped.map(typeKey).join(",") + (gci.literalSensitive.size > 0 ? `@${checkerKey}` : ""); + const literalText = (i: number): string | null => { + const arg = sensitive(i) ? tsArgs[i] : undefined; if (arg?.isStringLiteralType()) return JSON.stringify(arg.value); if (arg?.isNumberLiteralType()) return String(arg.value); if (arg && arg.flags & ts.TypeFlags.BooleanLiteral) return lowerer.checker.typeToString(arg); return null; }; - const literals = gci.typeParams.map((_, i) => literalKey(i)); - const key = - mapped.map(typeKey).join(",") + - (literals.some((l) => l !== null) ? `@${literals.map((l) => l ?? "*").join(",")}` : ""); const existing = gci.instances.get(key); if (existing) { if (existing.info) lowerer.noteGenericClassInstanceDemand(existing.info); @@ -4654,9 +4660,10 @@ export function genericClassInstanceType( const bindings = new Map(); gci.typeParams.forEach((tp, i) => bindings.set(tp, mapped[i]!)); const tsBindings = new Map(); - if (tsArgs.length === gci.typeParams.length) - gci.typeParams.forEach((tp, i) => tsBindings.set(tp, tsArgs[i]!)); - const rendered = mapped.map((m, i) => literals[i] ?? lowerer.fmt(m)).join(", "); + gci.typeParams.forEach((tp, i) => { + if (sensitive(i)) tsBindings.set(tp, tsArgs[i]!); + }); + const rendered = mapped.map((m, i) => literalText(i) ?? lowerer.fmt(m)).join(", "); const typeArgsText = `<${rendered.length > 80 ? rendered.slice(0, 77) + "..." : rendered}>`; const prevBindings = lowerer.typeParamBindings; const prevTsBindings = lowerer.typeParamTsBindings; @@ -4702,12 +4709,43 @@ export function genericClassInstanceType( /** The class's type parameters that a conditional type inside it depends * on: the parameters named within a type node that is a conditional type - * or refers to one (`get(): Value` with `type Value = K - * extends "required" ? T : T | undefined`). Their literal arguments pick - * the conditional's branch. */ + * or reaches one through the type aliases and interfaces it refers to + * (`get(): Value` with `type Value = K extends "required" ? T + * : T | undefined`, also when the alias wraps the conditional in a union + * or another alias). Their arguments pick the conditional's branch. */ function literalSensitiveTypeParams(lowerer: Lowerer, gci: GenericClassInfo): Set { const params = new Set(gci.typeParams); const found = new Set(); + const reaches = new Map(); + const declarationsReach = (symbol: ts.Symbol): boolean => { + const target = + symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol; + const known = reaches.get(target); + if (known !== undefined) return known; + reaches.set(target, false); // a recursive alias reaches through its other parts + const result = target.declarations.some((d) => + ts.isTypeAliasDeclaration(d) + ? reachesConditional(d.type) + : ts.isInterfaceDeclaration(d) + ? d.members.nodes.some(reachesConditional) + : false, + ); + reaches.set(target, result); + return result; + }; + const reachesConditional = (node: ts.Node): boolean => { + if (ts.isConditionalTypeNode(node)) return true; + if (ts.isTypeReferenceNode(node)) { + const name = ts.isIdentifier(node.typeName) ? node.typeName : node.typeName.right; + const symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol && !params.has(symbol) && declarationsReach(symbol)) return true; + } + let found = false; + ts.forEachChild(node, (child) => { + if (!found && reachesConditional(child)) found = true; + }); + return found; + }; const collect = (node: ts.Node): void => { if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { const symbol = lowerer.checker.getSymbolAtLocation(node.typeName); @@ -4716,12 +4754,9 @@ function literalSensitiveTypeParams(lowerer: Lowerer, gci: GenericClassInfo): Se ts.forEachChild(node, collect); }; const visit = (node: ts.Node): void => { - if ( - ts.isConditionalTypeNode(node) || - (ts.isTypeReferenceNode(node) && - lowerer.checker.getTypeFromTypeNode(node).isConditionalType()) - ) { + if (ts.isTypeNode(node) && reachesConditional(node)) { collect(node); + return; } ts.forEachChild(node, visit); }; diff --git a/tests/corpus/6908-generic-class-literal-conditional.ts b/tests/corpus/6908-generic-class-literal-conditional.ts index d06669be3..8bf7a0533 100644 --- a/tests/corpus/6908-generic-class-literal-conditional.ts +++ b/tests/corpus/6908-generic-class-literal-conditional.ts @@ -48,3 +48,18 @@ class Switch { } } console.log(new Switch(true).label(), new Switch(false).label()); + +// A conditional wrapped in a union alias selects its branch the same way. +type MaybeValue = (K extends "required" ? T : T | undefined) | null; +class Slot { + value: MaybeValue; + kind: K; + constructor(value: MaybeValue, kind: K) { + this.value = value; + this.kind = kind; + } +} +const filled = new Slot("a", "required"); +const empty = new Slot(undefined, "optional"); +const cleared = new Slot(null, "optional"); +console.log(filled.value, empty.value, cleared.value, filled.kind, empty.kind);