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<R, "a"> and Holder<R, "b"> 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.
This commit is contained in:
Chris Tate
2026-10-21 14:20:20 -05:00
parent 237a511174
commit 558fb7c8ba
2 changed files with 71 additions and 21 deletions
@@ -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<string, "required"> and Field<string,
// "optional"> 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<ts.Symbol, IrType>();
gci.typeParams.forEach((tp, i) => bindings.set(tp, mapped[i]!));
const tsBindings = new Map<ts.Symbol, ts.Type>();
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<T, K>` with `type Value<T, K> = 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<T, K>` with `type Value<T, K> = 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<ts.Symbol> {
const params = new Set(gci.typeParams);
const found = new Set<ts.Symbol>();
const reaches = new Map<ts.Symbol, boolean>();
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);
};
@@ -48,3 +48,18 @@ class Switch<B extends boolean> {
}
}
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<T, K extends Kind> = (K extends "required" ? T : T | undefined) | null;
class Slot<T, K extends Kind> {
value: MaybeValue<T, K>;
kind: K;
constructor(value: MaybeValue<T, K>, kind: K) {
this.value = value;
this.kind = kind;
}
}
const filled = new Slot<string, "required">("a", "required");
const empty = new Slot<string, "optional">(undefined, "optional");
const cleared = new Slot<number, "optional">(null, "optional");
console.log(filled.value, empty.value, cleared.value, filled.kind, empty.kind);