From 6014bb4ea501197bd8e0d723eb93cedc20b9a705 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 01:11:42 -0700 Subject: [PATCH] Keep a returned arrow's own return type in a void slot A value-returning arrow returned through a `() => void` interface member was adopted as void, so the enclosing function promised `() => number` while the lambda built a void closure: "a void result where the '() => number' slot promises ..." natively, though Node runs it (`repros/d2-void-slot-generic.ts`, pi `event-bus.ts:27`). A lambda that is the returned value of the function being lowered reaches that function's caller, so its own return type must survive the checker's void contextual type; the void adoption in lambdaSignature now skips such a lambda. Functions with a void result keep the existing discard semantics. tests/corpus/void-slot-returned-arrow.ts pins the shape both ways. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 25 ++++++++++++++++++ .../corpus/void-slot-returned-arrow.ts.json | 6 +++++ tests/corpus/void-slot-returned-arrow.ts | 26 +++++++++++++++++++ .../void-slot-returned-arrow.ts.json | 1 + 4 files changed, 58 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/void-slot-returned-arrow.ts.json create mode 100644 tests/corpus/void-slot-returned-arrow.ts create mode 100644 tests/harness/corpus-costs/void-slot-returned-arrow.ts.json diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 45329d077..98e78953f 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -1148,6 +1148,15 @@ export function lambdaSignature( // is the one the adoption block below takes for mappable inferred // returns, so this only changes programs that do not compile today. // + // A lambda RETURNED from the function being lowered flows into that + // function's result type, which OBSERVES its value — the checker's void + // contextual type at such a site comes from an outer assignment target + // that would discard the enclosing function's own result. So the void + // adoption below must not fire there: `{ on: (h) => () => seen.push("y") }` + // against a `(h) => void` field types the inner arrow `() => number` + // (its own type) while its contextual type is the discarded `() => void`, + // and the outer arrow's result is the `() => number` that must return it. + const returnedValue = returnedIntoValueSlot(lowerer, node); // The contextual signature is looked up at most once per lambda: both // this decision and the adoption block below need it, and the checker // call is not free in a compile this hot. @@ -1165,6 +1174,7 @@ export function lambdaSignature( ts.isArrowFunction(node) && !ts.isBlock(node.body) && isStreamUndefCallExpr(lowerer, node.body) ? VOID : (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && + !returnedValue && voidContextualSlot(lowerer, node, contextualSigs) ? VOID : lowerer.declaredReturnType(node, node); @@ -1292,6 +1302,7 @@ export function lambdaSignature( if ( !literalMethod && !isAsyncLike && + !returnedValue && !node.type && !inferredLiteral && ret.kind !== "void" && @@ -1352,6 +1363,20 @@ function voidContextualSlot( ); } +/** Whether `node` is the expression of a `return` in the function being + * lowered, and that function's result is a function type promising a + * value (`() => number`, not `() => void`). Such a lambda's value reaches + * the caller of the enclosing function, so its own return type is kept + * even when the checker's contextual type for it is `void` (see the ABI + * decision in lambdaSignature). */ +function returnedIntoValueSlot(lowerer: Lowerer, node: ts.Node): boolean { + let parent: ts.Node | undefined = node.parent; + while (parent !== undefined && ts.isParenthesizedExpression(parent)) parent = parent.parent; + if (parent === undefined || !ts.isReturnStatement(parent) || !parent.expression) return false; + const result = lowerer.ctx.returnType; + return result.kind === "func" && result.ret.kind !== "void"; +} + /** The type of the property an object-literal method fills on the * literal's contextual type; undefined when the literal has no contextual * type or the method has a computed name. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/void-slot-returned-arrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/void-slot-returned-arrow.ts.json new file mode 100644 index 000000000..cc7e1cea7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/void-slot-returned-arrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/void-slot-returned-arrow.ts" + ], + "diags": [] +} diff --git a/tests/corpus/void-slot-returned-arrow.ts b/tests/corpus/void-slot-returned-arrow.ts new file mode 100644 index 000000000..0abe87eac --- /dev/null +++ b/tests/corpus/void-slot-returned-arrow.ts @@ -0,0 +1,26 @@ +// A value-returning arrow RETURNED through a `() => void` interface member. +// TypeScript types the inner arrow `() => number` (its own type) while the +// checker contextually types it `() => void`; the enclosing arrow's result +// is `() => number`, so the value is observed and must be produced. Node +// runs this; the closure is called for its effect only. +interface Subscriber { + on(handler: (data: string) => void): () => void; +} + +function makeSubscriber(): Subscriber { + const seen: string[] = []; + return { + on: (handler) => { + seen.push("registered"); + handler("first"); + // Concise arrow whose body returns a number: the result is discarded + // at the `() => void` slot but the arrow's own type keeps it. + return () => seen.push("unsubscribed"); + }, + }; +} + +const off = makeSubscriber().on((data) => console.log("got", data)); +console.log("off is", typeof off); +off(); +console.log("done"); diff --git a/tests/harness/corpus-costs/void-slot-returned-arrow.ts.json b/tests/harness/corpus-costs/void-slot-returned-arrow.ts.json new file mode 100644 index 000000000..c321beddb --- /dev/null +++ b/tests/harness/corpus-costs/void-slot-returned-arrow.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 2400 }