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.
This commit is contained in:
Malte Ubl
2026-10-11 08:17:00 -07:00
parent 4831665524
commit bc89955859
2 changed files with 105 additions and 0 deletions
@@ -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<typeof setInterval> | 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([]);
});
@@ -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(