Complete function-value arguments in optional calls

The `f?.()` branch of the optional-chain lowering forwarded a spread
argument list through completeFuncValueArgs but mapped a plain argument
list itself, so an omitted optional parameter was never padded with the
callee's undefined arm. The lib-boundary arity fence then refused the call
("2 arguments where its lowered signature takes 3"), which is what
`callbacks?.onRetryFinished?.(false, attempt)` hit: an unguarded call
completes its arguments, a guarded one did not.

Run both spellings through the same completion. It evaluates inside the
guard, so a nullish callee still evaluates no argument, and a typed rest
now packs from the guarded call too.

Corpus: optional-chained callbacks with a trailing optional parameter and
with a typed rest, through a present, absent, and undefined receiver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f574073e2392ec24cd2cfca61651759d3359e603)
This commit is contained in:
Malte Ubl
2026-10-28 19:49:28 -07:00
parent 93765cd6a2
commit 5f169ed6fe
4 changed files with 78 additions and 19 deletions
@@ -1,5 +1,4 @@
import { isNavigatorGlobal } from "../builtins/navigator.js";
import { isSafeToDiscard } from "./evaluation-safety.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
@@ -449,24 +448,20 @@ export function lowerOptionalChain(
// directly (no member dispatch exists to re-enter).
if (dotNode === expr && ts.isCallExpression(expr)) {
if (narrowed.kind !== "func") lowerer.badType(recvNode, lowerer.typeOf(recvNode));
const params = narrowed.params;
// Forwarded fixed tuples (`f?.(...args)`) complete into the slots.
if (expr.arguments.nodes.some(ts.isSpreadElement)) {
const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc);
const call: IrExpr = {
kind: "callValue",
callee: recvRef,
args: completed,
type: narrowed.ret,
loc,
};
return lowerer.finishOptionalChain(expr, id, receiver, call, loc);
}
const args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, params[i]));
// Effect-free surplus arguments (a dropped `void` slot) are ignored.
while (narrowed.rest !== true && args.length > params.length && isSafeToDiscard(args.at(-1)!))
args.pop();
const body: IrExpr = { kind: "callValue", callee: recvRef, args, type: narrowed.ret, loc };
// The same completed function-value ABI an unguarded call uses: an
// omitted optional argument still reaches the callee as undefined
// (`cb?.onRetryFinished?.(false, attempt)` must see its third
// parameter), a forwarded tuple completes into the slots, and an
// effect-free surplus argument is ignored. The completion runs INSIDE
// the guard, so a nullish callee still evaluates no argument.
const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc);
const body: IrExpr = {
kind: "callValue",
callee: recvRef,
args: completed,
type: narrowed.ret,
loc,
};
return lowerer.finishOptionalChain(expr, id, receiver, body, loc);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/function-value-arity-completion.ts"
],
"diags": []
}
@@ -0,0 +1,57 @@
// Calling a function value short is legal TypeScript whenever the omitted
// suffix is optional: the native callee still receives every parameter, so
// the call completes the missing slots with undefined exactly as the JS call
// would.
//
// The same holds for a call through an optional-chained property
// (`cb?.onRetryFinished?.(false, attempt)`), which evaluates the callee and
// its arguments once, in order.
interface RetryCallbacks {
onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void;
onAttempt?: (attempt: number, ...notes: string[]) => string;
}
function finish(callbacks: RetryCallbacks | undefined, ok: boolean, attempt: number): string[] {
const log: string[] = [];
const sink: RetryCallbacks = {
onRetryFinished: (success, n, finalError) => {
log.push(`finished ${success ? "ok" : "no"} ${n} ${finalError === undefined ? "none" : finalError}`);
},
onAttempt: (n, ...notes) => `${n}:${notes.join("+")}`,
};
sink.onRetryFinished?.(ok, attempt);
sink.onRetryFinished?.(ok, attempt, "boom");
callbacks?.onRetryFinished?.(ok, attempt + 1);
callbacks?.onAttempt?.(attempt, "a", "b");
return log;
}
console.log(finish(undefined, true, 1).join("; "));
console.log(
finish(
{
onRetryFinished: (success, attempt, finalError) => {
console.log(`callback ${success} ${attempt} ${finalError ?? "-"}`);
},
onAttempt: (attempt, ...notes) => {
console.log(`attempt ${attempt} ${notes.length}`);
return "ignored";
},
},
false,
2,
).join("; "),
);
// An omitted trailing argument for a callback whose parameter is optional in
// the middle of a longer signature.
type Reporter = (message: string, code?: number, detail?: string) => string;
function report(to: Reporter): void {
console.log(to("first"));
console.log(to("second", 42));
console.log(to("third", undefined, "why"));
}
report((message, code, detail) => `${message}/${code ?? 0}/${detail ?? "-"}`);
@@ -0,0 +1 @@
{ "plain": 5094, "san": 6883 }