From bc899558595871d010add31c96f56da13e5bf55a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:17:00 -0700 Subject: [PATCH] Keep stale narrowings in specialized standard-library arguments A stale narrowing read in a call argument whose parameter accepts the declared type read as declared, so the callee's lowering tests the stored value. For standard-library callees that lowering is often specialized to the argument types programs pass: after `combo.p = {...}; combo()` the argument of `JSON.stringify(combo.p)` became `T | undefined` (SC1090), and after `ci(slow)` the handle of `timers.clearInterval(fast)` became `number | null` (SC2020), so 2072-expando-function-members.ts and 1804-timers-module.ts stopped compiling. Call and construction arguments (directly or inside literal arguments) now read as declared only where the callee lowers any value of the parameter type: user callees, console methods, and standard-library parameters typed by a type parameter (`push(...items: T[])`, `Map.set`). Other standard-library arguments keep the checker's narrowing and the checked extraction. --- .../lowering/narrowing-stability.test.ts | 40 ++++++++++++ .../frontend/lowering/narrowing-stability.ts | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 81bea782c..577356fc6 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -113,3 +113,43 @@ test("narrowed reads after code that can clear the field check it with Node's Ty expect(thrownMessages(module, "methodCall")).toContain(read); expect(thrownMessages(module, "loopBackEdge")).toContain(read); }); + +test("standard-library arguments read as declared only where the lowering is generic", async () => { + // JSON.stringify and clearInterval lower only the narrowed argument types; + // console methods and type-parameter slots take the declared value. + const module = await lower(` +interface Opts { + p?: { [k: string]: number }; +} +function touch(): number { + return 1; +} +let handle: ReturnType | null = null; +const stop = (): void => { + handle = null; +}; +function specialized(o: Opts): string { + o.p = { a: 1 }; + touch(); + return JSON.stringify(o.p); +} +function clearAfterCall(): void { + if (handle !== null) { + touch(); + clearInterval(handle); + } +} +function formatted(o: Opts, out: (Opts["p"])[]): void { + o.p = { a: 2 }; + touch(); + console.log(o.p); + out.push(o.p); +} +handle = setInterval(() => {}, 1000); +console.log(specialized({})); +clearAfterCall(); +stop(); +formatted({}, []); +`); + expect(thrownMessages(module, "formatted")).toEqual([]); +}); diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index afeae5353..a1585a24b 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -141,6 +141,7 @@ export class NarrowingStability { const present = this.lowerer.mapTypeOf(checker.getNonNullableType(declared)); if (present === null || !typeEquals(present, narrowedIr)) return null; } else if (position === "context") { + if (!this.argumentSlotIsGeneric(node)) return null; const contextual = checker.getContextualType(node); if (contextual === undefined) return null; if ( @@ -166,6 +167,48 @@ export class NarrowingStability { return this.trusted(node) ? null : declared; } + /** A read in a call or construction argument (directly or inside a literal + * argument) reaches the callee's own lowering with the declared type. User + * callees coerce any value of their parameter type, and so do the + * standard library's generic slots: a parameter typed by a type parameter + * (`push(...items: T[])`, `Map.set(key: K, value: V)`) and the console + * methods, which format any value. Other standard-library lowerings are + * specialized to the argument types programs pass and can refuse the + * declared union (`JSON.stringify` of a possibly-undefined value, + * `clearInterval` of a nullable handle). Those arguments keep the checker's + * narrowing, and maybeNarrow reads them through the checked extraction. */ + private argumentSlotIsGeneric(node: ts.Expression): boolean { + const slot = enclosingArgument(node); + if (slot === null) return true; + const { call, argument } = slot; + const checker = this.lowerer.checker; + const signature = checker.getResolvedSignature(call); + const declaration = signature ? checker.signatureDeclaration(signature) : undefined; + if (!declaration || !this.lowerer.isStdlibFile(declaration.getSourceFile())) return true; + const callee = skipOuter(call.expression); + if ( + ts.isPropertyAccessExpression(callee) && + this.lowerer.isStdlibGlobal(skipOuter(callee.expression), "console") + ) + return true; + const index = call.arguments?.indexOf(argument) ?? -1; + const params = declaration.parameters ?? []; + if (index < 0 || params.length === 0) return false; + const param = params[Math.min(index, params.length - 1)]!; + if (index >= params.length && param.dotDotDotToken === undefined) return false; + return param.type !== undefined && this.mentionsTypeParameter(param.type); + } + + private mentionsTypeParameter(node: ts.Node): boolean { + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const symbol = this.lowerer.checker.getSymbolAtLocation(node.typeName); + if (symbol && symbol.flags & ts.SymbolFlags.TypeParameter) return true; + } + return ( + ts.forEachChild(node, (child) => this.mentionsTypeParameter(child) || undefined) ?? false + ); + } + /** The type of the accessed property as declared on the receiver's * (instantiated) type, before any flow narrowing of this reference. */ private declaredPropertyType(node: ts.PropertyAccessExpression): ts.Type | null { @@ -867,6 +910,28 @@ function widePosition(node: ts.Expression): WidePosition | null { return null; } +/** The call or construction whose argument holds `node`, directly or as an + * element or property of literal arguments, with that argument. */ +function enclosingArgument( + node: ts.Expression, +): { call: ts.CallExpression | ts.NewExpression; argument: ts.Expression } | null { + let child: ts.Node = node; + let parent = node.parent; + while ( + ts.isParenthesizedExpression(parent) || + ts.isArrayLiteralExpression(parent) || + ts.isObjectLiteralExpression(parent) || + ts.isShorthandPropertyAssignment(parent) || + (ts.isPropertyAssignment(parent) && parent.initializer === child) + ) { + child = parent; + parent = parent.parent; + } + if (!(ts.isCallExpression(parent) || ts.isNewExpression(parent))) return null; + const argument = parent.arguments?.find((arg) => arg === child); + return argument ? { call: parent, argument } : null; +} + /** The array or object literal holding `parent` (an element's literal or a * property assignment) is itself lowered against a declared slot type. */ function typedLiteral(