From 171214b2c214e71c6dda67b9401859dbf8b0e967 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 14:40:32 -0700 Subject: [PATCH 1/2] Discard the result of void generic methods called through class-backed interfaces A generic method called through an interface value that classes implement dispatches by name on the checked-dynamic receiver, and the result converts to the call's instantiated return type. For a void method, that conversion (`unknown` to `void`) was refused (SC1100). The result is now discarded, as JavaScript does. The dispatch helper selected overriding subclasses' erased instances with a ternary, which has no void form (an internal compiler error once the call got that far). A void method now selects them with if statements. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 50 ++++++++++++------- .../src/frontend/lowering/lower-calls.ts | 13 ++++- .../generic-interface-void-calls.ts.json | 6 +++ tests/corpus/generic-interface-void-calls.ts | 45 +++++++++++++++++ .../generic-interface-void-calls.ts.json | 1 + 5 files changed, 96 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-interface-void-calls.ts.json create mode 100644 tests/corpus/generic-interface-void-calls.ts create mode 100644 tests/harness/corpus-costs/generic-interface-void-calls.ts.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 2af84f8fb..7992f7fec 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -6793,24 +6793,41 @@ export class ClassDynamicDispatch { type: result, loc, }; + // A void method dispatches its overrides with statements: a ternary + // has no void form. + let voidDispatch: IrStmt[] = [{ kind: "exprStmt", expr: call, loc }]; for (const override of overrides.toReversed()) { const subType: IrType = { kind: "object", className: override.cls.def.name }; + const isOverride: IrExpr = { + kind: "instanceOf", + value: receiver, + className: override.cls.def.name, + type: BOOL, + loc, + }; + const overrideCall: IrExpr = { + kind: "call", + callee: override.callee, + args: [{ kind: "downcast", value: receiver, type: subType, loc }, ...args], + type: result, + loc, + }; + if (result.kind === "void") { + voidDispatch = [ + { + kind: "if", + cond: isOverride, + then: [{ kind: "exprStmt", expr: overrideCall, loc }], + else_: voidDispatch, + loc, + }, + ]; + continue; + } call = { kind: "ternary", - cond: { - kind: "instanceOf", - value: receiver, - className: override.cls.def.name, - type: BOOL, - loc, - }, - then: { - kind: "call", - callee: override.callee, - args: [{ kind: "downcast", value: receiver, type: subType, loc }, ...args], - type: result, - loc, - }, + cond: isOverride, + then: overrideCall, else_: call, type: result, loc, @@ -6818,10 +6835,7 @@ export class ClassDynamicDispatch { } let body: IrStmt[]; if (result.kind === "void") - body = [ - { kind: "exprStmt", expr: call, loc }, - { kind: "return", value: dynUndefinedExpr(loc), loc }, - ]; + body = [...voidDispatch, { kind: "return", value: dynUndefinedExpr(loc), loc }]; else { const boxed = lowerer.coerceToExpected(call, DYN); if (boxed.type.kind !== "dyn") return fence(); diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index c8f02562e..e2f7ade90 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -10073,7 +10073,18 @@ function lowerObjLitGenericMethodCall( type: DYN, loc, }; - return lowerer.coerceInto(call, invoked, lowerer.irTypeOf(call)); + const resultType = lowerer.irTypeOf(call); + // A void method's runtime result is discarded, as JavaScript does. + if (resultType.kind === "void") { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: invoked, loc }], + result: { kind: "libCall", fn: "console.noop", args: [], type: VOID, loc }, + type: VOID, + loc, + }; + } + return lowerer.coerceInto(call, invoked, resultType); } } if (onInterface) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-interface-void-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-interface-void-calls.ts.json new file mode 100644 index 000000000..a5c69b714 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-interface-void-calls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-interface-void-calls.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-interface-void-calls.ts b/tests/corpus/generic-interface-void-calls.ts new file mode 100644 index 000000000..d810383f7 --- /dev/null +++ b/tests/corpus/generic-interface-void-calls.ts @@ -0,0 +1,45 @@ +// Calling a void generic method through an interface value that classes +// implement (the receiver is a checked-dynamic object, and the call +// dispatches by name to the class's erased method) discards the result, as +// JavaScript does, instead of converting it to `void`. + +interface Message { + content: string; + details?: T; +} + +interface Channel { + name: string; + publish(message: Message): void; + count(items: T[]): number; +} + +const delivered: string[] = []; + +class LogChannel implements Channel { + name: string; + constructor(name: string) { + this.name = name; + } + publish(message: Message): void { + delivered.push(`${this.name}:${message.content}:${message.details === undefined ? "-" : JSON.stringify(message.details)}`); + } + count(items: T[]): number { + return items.length; + } +} + +class LoudChannel extends LogChannel { + publish(message: Message): void { + super.publish({ content: message.content.toUpperCase(), details: message.details }); + } +} + +const channels: Channel[] = [new LogChannel("quiet"), new LoudChannel("loud")]; +for (const channel of channels) { + channel.publish({ content: "hello", details: [1, 2] }); + channel.publish({ content: "bare" }); + const result = channel.publish({ content: "typed", details: "x" }); + console.log(channel.name, result === undefined, channel.count(["a", "b", "c"])); +} +console.log(delivered.join(" | ")); diff --git a/tests/harness/corpus-costs/generic-interface-void-calls.ts.json b/tests/harness/corpus-costs/generic-interface-void-calls.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/generic-interface-void-calls.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } From 693b038b15432b25d446b1352391858e7c545e9f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 18:17:32 -0500 Subject: [PATCH 2/2] Keep the result of void generic methods called through class-backed interfaces A class may implement a void interface method with a method that returns a value, and JavaScript passes that value on (`console.log(c.publish(m))`). The call now keeps the dispatch's untyped result, as a call through a non-generic void member already does, instead of replacing it with undefined: printing or interpolating it shows the value, and binding it to a void-typed name checks it. --- .../src/frontend/lowering/lower-calls.ts | 15 +++++--------- tests/corpus/generic-interface-void-calls.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e2f7ade90..265b7553b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -10074,16 +10074,11 @@ function lowerObjLitGenericMethodCall( loc, }; const resultType = lowerer.irTypeOf(call); - // A void method's runtime result is discarded, as JavaScript does. - if (resultType.kind === "void") { - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: invoked, loc }], - result: { kind: "libCall", fn: "console.noop", args: [], type: VOID, loc }, - type: VOID, - loc, - }; - } + // A void method's result stays the runtime's untyped value, as a + // call through a non-generic void member's dispatch keeps it: a class + // may implement the member with a method that returns a value, which + // JavaScript passes on (`console.log(c.publish(m))`). + if (resultType.kind === "void") return invoked; return lowerer.coerceInto(call, invoked, resultType); } } diff --git a/tests/corpus/generic-interface-void-calls.ts b/tests/corpus/generic-interface-void-calls.ts index d810383f7..63c137c10 100644 --- a/tests/corpus/generic-interface-void-calls.ts +++ b/tests/corpus/generic-interface-void-calls.ts @@ -43,3 +43,23 @@ for (const channel of channels) { console.log(channel.name, result === undefined, channel.count(["a", "b", "c"])); } console.log(delivered.join(" | ")); + +// A class may implement the void member with a method that returns a value; +// JavaScript passes that value on. +class CountingChannel implements Channel { + name = "counting"; + sent = 0; + publish(message: Message): number { + this.sent++; + return message.content.length; + } + count(items: T[]): number { + return items.length * 2; + } +} + +const counted: Channel[] = [new CountingChannel(), new LogChannel("plain")]; +for (const channel of counted) { + console.log(channel.name, channel.publish({ content: "four" })); + console.log(`sent ${channel.publish({ content: "again", details: 2 })}`); +}