From f25cb00da894208b4ba2bbea5c6a81069457e512 Mon Sep 17 00:00:00 2001 From: not-the-ccp Date: Sat, 22 Aug 2026 18:31:06 +0200 Subject: [PATCH 1/2] fix: compile void ternaries in discarded-value positions (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conditional expression over two `void` calls (`flag ? a() : b()`) tripped SC9001 "ternary must not be void" everywhere — including the positions the SC1090 'void' hint advertises as supported: statement position (`flag ? a() : b();`) and void-returning concise arrow bodies (`const pick = () => (flag ? a() : b())`, the shape in #33). JS discards the value at those sites, so they now lower exactly: the already-lowered pieces are reshaped into if/else statements (voidTernaryIfStmt), recursing through nested arms. The reshape applies at every discarded-value landing — lowerExprStatement's fallback, both concise-arrow-body paths, and lowerLambda's. A CONSUMED void ternary still has no IR representation; it now gets a pointed SC1090 ("write it as an if/else statement, or as a void-returning arrow body") instead of the validator ICE, gated on a stateless parent walk that recognizes exactly the discard sites. Corpus 2692 pins the compiled behavior against Node byte-for-byte; diagnostics/void-ternary-value-position snapshots both fence shapes. Fixes #33 --- .../src/frontend/lowering/lower-calls.ts | 11 ++-- .../src/frontend/lowering/lower-exprs.ts | 62 +++++++++++++++++++ .../src/frontend/lowering/lower-stmts.ts | 32 ++++++++++ tests/corpus/2692-void-ternary-statement.ts | 45 ++++++++++++++ .../void-ternary-value-position.ts | 17 +++++ .../void-ternary-value-position.ts.txt | 13 ++++ 6 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 tests/corpus/2692-void-ternary-statement.ts create mode 100644 tests/diagnostics/void-ternary-value-position.ts create mode 100644 tests/harness/__snapshots__/void-ternary-value-position.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 63427842..b2284c60 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -18,6 +18,7 @@ import { mixinFnShapeOf } from "./lower-mixins.js"; import { bufEncoding, dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerRegexMethodCall, lowerStringMethodCall, lowerTupleReadMethodCall } from "./lower-containers.js"; import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerDirentMethodCall, lowerFileHandleMethodCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerWatcherMethodCall } from "./lower-builtins.js"; import { droppableStatic, lowerAbsenceProbe, lowerPromiseAllTupleCall, lowerPromiseRejectCall, probeLower, templateRawTextOf } from "./lower-exprs.js"; +import { voidTernaryIfStmtOrExprStmt } from "./lower-stmts.js"; import { httpClientFnBindingOf, isStreamUndefCallExpr, lowerCompatReqStreamOptionalCall, lowerHttpClientFnCall } from "./lower-server.js"; import { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; import { emitterRooted, lowerEmitterMethodCall } from "./lower-emitter.js"; @@ -1456,7 +1457,7 @@ export function genericFnOf(L: Lowerer, ident: ts.Identifier): GenericFnInfo | n if (fnCtx.inferReturn) { const value = L.lowerExpr(decl.body); if (value.type.kind === "void") { - body.push({ kind: "exprStmt", expr: value, loc: locOf(decl.body) }); + body.push(voidTernaryIfStmtOrExprStmt(value, locOf(decl.body))); bodyReturn = resolveInferredReturn(L, inst, fnCtx.inferReturn, body, decl); appendImplicitUndefinedReturn(L, body, bodyReturn, locOf(decl)); } else { @@ -1468,7 +1469,7 @@ export function genericFnOf(L: Lowerer, ident: ts.Identifier): GenericFnInfo | n } else { const value = L.lowerExprExpecting(decl.body, bodyReturn); if (bodyReturn.kind === "void") { - body.push({ kind: "exprStmt", expr: value, loc: locOf(decl.body) }); + body.push(voidTernaryIfStmtOrExprStmt(value, locOf(decl.body))); } else { body.push({ kind: "return", value, loc: locOf(decl.body) }); } @@ -5815,7 +5816,9 @@ const inliningPredicates = new Set(); body = [L.lowerExprStatement(stripped)]; } else { const value = L.lowerExpr(bodyExpr); - body = value.kind === "unitLit" ? [] : [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]; + body = value.kind === "unitLit" + ? [] + : [voidTernaryIfStmtOrExprStmt(value, locOf(node.body!))]; } } else { let value = L.lowerExpr(bodyExpr); @@ -5828,7 +5831,7 @@ const inliningPredicates = new Set(); } body = value.type.kind === "void" && L.wrappedUndefined(bodyReturn, locOf(node.body!)) - ? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }] + ? [voidTernaryIfStmtOrExprStmt(value, locOf(node.body!))] : [ { kind: "return", diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 470b9ffe..9bf2f45a 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2184,6 +2184,20 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { (useCtx && ctxMapped ? ctxMapped : L.irTypeOf(expr))); + // A VOID join (`flag ? a() : b()` over two void arms): the conditional + // carries no value, so it only compiles where JS discards it — those + // sites (lowerExprStatement, the void-returning concise arrow bodies) + // reshape the already-lowered pieces into if/else AFTER this returns. + // A CONSUMED void result has no representation, so every other site is + // fenced by name here rather than tripping the validator's + // "ternary must not be void" ICE downstream. + if (type.kind === "void" && !discardedVoidTernarySite(L, expr)) { + L.unsupported( + "SC1090", + expr, + "a conditional expression over 'void' in value position (write it as an if/else statement, or as a void-returning arrow body)", + ); + } // Each arm flows into the ternary's type through the slot-coercion // path: union arms wrap, dyn slots reject the non-dyn arm with // SC1101, mismatched record shapes get SC2002; coerceInto is @@ -2201,6 +2215,54 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { } } +/** True when JS discards this conditional expression's value at exactly + * this site: an expression statement — the `void e` and comma spellings + * included, they hand their operands to the same statement lowering — or + * the concise body of a void-returning arrow. Those are the paths that + * reshape a lowered VOID ternary into if/else statements + * (lowerExprStatement / the concise-arrow-body lowerings), so the void + * join is legal here. An ARM of another conditional counts only when the + * enclosing ternary ITSELF joins to void — that is the ternary the + * reshape rewrites, recursively. Everywhere else the undefined result is + * CONSUMED, which no expression shape carries, and lowerTernary fences + * by name. */ + function discardedVoidTernarySite(L: Lowerer, expr: ts.ConditionalExpression): boolean { + let n: ts.Node = expr; + for (;;) { + while (n.parent && ts.isParenthesizedExpression(n.parent)) n = n.parent; + const p = n.parent; + if (!p) return false; + if (ts.isExpressionStatement(p)) return true; + if (ts.isVoidExpression(p)) { + n = p; + continue; + } + if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.CommaToken) { + n = p; + continue; + } + // An arm counts only under a ternary that ITSELF joins to void — + // that is the ternary the reshape rewrites into if/else. A sibling + // value (`c ? a() : 0`) makes the join non-void: the enclosing + // ternary stays an expression, its void arm would need control flow + // mid-expression, and the fence fires here. + if ( + ts.isConditionalExpression(p) && + (p.whenTrue === n || p.whenFalse === n) && + (L.checker.getTypeAtLocation(p).flags & ts.TypeFlags.Void) !== 0 + ) { + n = p; + continue; + } + if (ts.isArrowFunction(p) && p.body === n && !ts.isBlock(p.body)) { + const sig = L.checker.getSignatureFromDeclaration(p); + if (!sig) return false; + return (L.checker.getReturnTypeOfSignature(sig).flags & ts.TypeFlags.Void) !== 0; + } + return false; + } + } + /** Checker-driven union narrowing. tsc's control-flow analysis narrows a * union-typed reference at use sites (`if (r.kind === "ok") { ...r... }` * types `r` as the ok-arm inside the branch); the IR value is still the diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 1d6202df..c6be2089 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4707,9 +4707,41 @@ function isEsModuleStamp(expr: ts.Expression): boolean { // and the IR has no bare-unit VALUE outside a union wrap — drop it // instead of tripping the validator's bare-unitLit rule. if (value.kind === "unitLit") return { kind: "block", body: [], loc: locOf(expr) }; + // `flag ? a() : b();` over two void arms: statement position discards + // the value, so the exact form is if/else over the same lowered pieces + // (a void ternary EXPRESSION is fenced everywhere else). + if (value.kind === "ternary" && value.type.kind === "void") return voidTernaryIfStmt(value); return { kind: "exprStmt", expr: value, loc: locOf(expr) }; } +/** Statement form of a lowered VOID ternary: JS evaluates the condition, + * runs exactly the taken arm, and discards both arms' values — an if/else + * over the already-lowered pieces does precisely that (each arm is void, + * so nothing observable is dropped). The validator fences void ternaries + * as EXPRESSIONS ("ternary must not be void"), so every discarded-value + * site routes through here instead of wrapping one in an exprStmt. An + * arm can itself be a nested void ternary (`deep ? (flag ? a() : b()) + * : c()` — the inner sits in value position while the outer lowers), so + * the rewrite recurses. */ + export function voidTernaryIfStmt(value: IrExpr & { kind: "ternary" }): IrStmt { + return { + kind: "if", + cond: value.cond, + then: [voidTernaryIfStmtOrExprStmt(value.then, value.then.loc)], + else_: [voidTernaryIfStmtOrExprStmt(value.else_, value.else_.loc)], + loc: value.loc, + }; + } + +/** The statement a lowered VOID expression lands in at a discarded-value + * site (concise arrow bodies returning void): the if/else form when the + * expression is a void ternary, the plain exprStmt otherwise. */ + export function voidTernaryIfStmtOrExprStmt(value: IrExpr, loc: SrcLoc): IrStmt { + return value.kind === "ternary" && value.type.kind === "void" + ? voidTernaryIfStmt(value) + : { kind: "exprStmt", expr: value, loc }; + } + /** True for a statement-position expression whose evaluation cannot be * observed once the value is discarded: * - a property/element READ (`?.` included) whose receiver IS a diff --git a/tests/corpus/2692-void-ternary-statement.ts b/tests/corpus/2692-void-ternary-statement.ts new file mode 100644 index 00000000..74b3a2bf --- /dev/null +++ b/tests/corpus/2692-void-ternary-statement.ts @@ -0,0 +1,45 @@ +// A conditional expression over two `void` calls carries no value — it only +// compiles where JS discards it: statement position and void-returning +// concise arrow bodies. Those lower as if/else over the taken arm; a +// CONSUMED void ternary stays fenced. +let log = ""; +function a(): void { + log += "a"; +} +function b(): void { + log += "b"; +} +function loud(tag: string): void { + log += tag; +} + +// statement position: only the taken arm's effects run +const flag = process.argv.length > 99; +flag ? a() : b(); +false ? loud("skipped") : b(); +true ? a() : loud("skipped"); + +// nested arms: the inner ternary rides the outer's if/else reshape +const deep = process.argv.length > 99; +deep ? (flag ? a() : loud("x")) : b(); +flag ? a() : deep ? loud("y") : b(); + +// concise arrow bodies returning void (SC1090's documented surface) +const pick = (n: number) => (n > 0 ? a() : b()); +pick(1); +pick(-1); + +// generic arrow with an inferred void return +const ident = (v: T) => (typeof v === "string" ? loud("s") : b()); +ident("x"); +ident(1); + +// narrowing reaches both arms +const use = (n: string | number) => (typeof n === "string" ? loud(`str:${n}`) : loud("num")); +use("hi"); +use(7); + +// comma spelling hands its left operand to the same statement lowering +void (flag ? a() : b(), 0); + +console.log(log); diff --git a/tests/diagnostics/void-ternary-value-position.ts b/tests/diagnostics/void-ternary-value-position.ts new file mode 100644 index 00000000..3eee320d --- /dev/null +++ b/tests/diagnostics/void-ternary-value-position.ts @@ -0,0 +1,17 @@ +// A conditional expression over two `void` calls has no value — statement +// position and void-returning arrow bodies compile (corpus 2692), but a +// CONSUMED void ternary has no IR representation: fence by name instead of +// the validator's "ternary must not be void" ICE. +function a(): void { + console.log("a"); +} +function b(): void { + console.log("b"); +} +const x = process.argv.length > 99 ? a() : b(); +console.log(typeof x); + +// A void arm under a NON-void sibling stays consumed: the enclosing ternary +// keeps expression form, so the void arm fences too. +const flag = process.argv.length > 99; +flag ? (flag ? a() : b()) : 0; diff --git a/tests/harness/__snapshots__/void-ternary-value-position.ts.txt b/tests/harness/__snapshots__/void-ternary-value-position.ts.txt new file mode 100644 index 00000000..7a5ae26c --- /dev/null +++ b/tests/harness/__snapshots__/void-ternary-value-position.ts.txt @@ -0,0 +1,13 @@ +void-ternary-value-position.ts:11:11 - error SC1090: a conditional expression over 'void' in value position (write it as an if/else statement, or as a void-returning arrow body) is not supported yet + + 10 | } + 11 | const x = process.argv.length > 99 ? a() : b(); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 12 | console.log(typeof x); + +void-ternary-value-position.ts:17:9 - error SC1090: a conditional expression over 'void' in value position (write it as an if/else statement, or as a void-returning arrow body) is not supported yet + + 16 | const flag = process.argv.length > 99; + 17 | flag ? (flag ? a() : b()) : 0; + | ^~~~~~~~~~~~~~~~ + 18 | \ No newline at end of file From df314023688500483848bfc8079a4cfe04c02f90 Mon Sep 17 00:00:00 2001 From: not-the-ccp Date: Sat, 22 Aug 2026 18:48:32 +0200 Subject: [PATCH 2/2] fix: reshape void ternaries in void-return statements too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return flag ? a() : b();` in a void-returning function drops the value exactly like the statement and arrow-body sites — it now lowers to the same if/else reshape, with an explicit bare return in each branch so trailing statements stay unreachable. The discard-site walk recognizes the position via ctx.returnType; non-void returns keep their SC0001 (void is not assignable), which fires before lowering either way. --- .../src/frontend/lowering/lower-exprs.ts | 4 ++++ .../compiler/src/frontend/lowering/lowerer.ts | 20 ++++++++++++++++++- tests/corpus/2692-void-ternary-statement.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 9bf2f45a..69407dcf 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2233,6 +2233,10 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { const p = n.parent; if (!p) return false; if (ts.isExpressionStatement(p)) return true; + // `return e;` in a void-returning function drops the value too: the + // return lowering reshapes the ternary into branching arms that each + // complete the return. + if (ts.isReturnStatement(p)) return L.ctx.returnType.kind === "void"; if (ts.isVoidExpression(p)) { n = p; continue; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 936d06f5..09dee18b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -105,7 +105,7 @@ import { lowerNodeTestModuleCall, lowerTestDirectCall, lowerTestMethodCall, lowe import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js"; import { lowerUtilModuleCall } from "./lower-inspect.js"; import { lowerComptime, comptimeBakeable, rejectComptimeCaptures, comptimeValueToIr } from "./lower-comptime.js"; -import { lowerStmts, noteBlockedBindings, isBlockedBinding, lowerScopedBlock, predeclareForwardCapture, predeclareForwardFnDecl, predeclareForwardVar, rejectJumpCrossingFinally, lowerStmt, lowerVarStatement, lowerDestructuringDecl, lowerDestructuringAssignParts, lowerBindingPattern, lowerJsvalBindingPattern, checkBindingElement, bindPatternTarget, isParseArgsDynCheckerType, lowerVarDeclList, lowerVarDecl, lowerSwitch, lowerTry, lowerExprStatement, lowerForOf, lowerForStatement } from "./lower-stmts.js"; +import { lowerStmts, noteBlockedBindings, isBlockedBinding, lowerScopedBlock, predeclareForwardCapture, predeclareForwardFnDecl, predeclareForwardVar, rejectJumpCrossingFinally, lowerStmt, lowerVarStatement, lowerDestructuringDecl, lowerDestructuringAssignParts, lowerBindingPattern, lowerJsvalBindingPattern, checkBindingElement, bindPatternTarget, isParseArgsDynCheckerType, lowerVarDeclList, lowerVarDecl, lowerSwitch, lowerTry, lowerExprStatement, voidTernaryIfStmtOrExprStmt, lowerForOf, lowerForStatement } from "./lower-stmts.js"; import { FieldTarget, lowerDynObjectLiteral, lowerExpr, maybeNarrow, lowerUnitComparison, lowerNullishCoalesce, lowerOptionalChain, finishOptionalChain, lowerCondition, ensureBool, requireTruthyUnion, eqComparableUnion, lowerIntrinsicProperty, lowerArrayLiteral, lowerObjectLiteral, lowerShorthandValue, rejectThisInObjectMethod, lowerElementAccess, lowerElementWrite, lowerRecordKeyRead, ensureString, lowerTemplate, lowerAsExpression, lowerPrefixUnary, lowerBinary, lowerCaughtTypeofTest, caughtRead, caughtLocalOf, caughtToString, lowerInstanceOf, lowerRegexLiteral, lowerFieldRead, lowerUnionProperty, fieldTarget, fieldGetExpr, fieldSetStmt, lowerFieldCompound, uniqueSymbolKeyOf, foldedStringKeyOf } from "./lower-exprs.js"; import type { ExpandoMember } from "./lower-expando.js"; import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; @@ -6655,6 +6655,24 @@ export class Lowerer { e = { kind: "awaitExpr", value: e, type: e.type.inner, loc: e.loc }; } if (e.kind === "unitLit") return { kind: "return", value: null, loc }; + // `return flag ? a() : b();` over two void arms: JS runs exactly the + // taken arm for effect and completes — an if/else over the lowered + // pieces does precisely that. Each branch carries its own explicit + // bare return so statements after this one stay unreachable; the + // arm landing keeps the rewrite recursive for nested void ternaries. + if (e.kind === "ternary" && e.type.kind === "void") { + const armStmts = (arm: IrExpr): IrStmt[] => [ + voidTernaryIfStmtOrExprStmt(arm, arm.loc), + { kind: "return", value: null, loc }, + ]; + return { + kind: "if", + cond: e.cond, + then: armStmts(e.then), + else_: armStmts(e.else_), + loc, + }; + } if (e.type.kind === "void") return { kind: "return", value: e, loc }; return { kind: "block", diff --git a/tests/corpus/2692-void-ternary-statement.ts b/tests/corpus/2692-void-ternary-statement.ts index 74b3a2bf..0ba79bfd 100644 --- a/tests/corpus/2692-void-ternary-statement.ts +++ b/tests/corpus/2692-void-ternary-statement.ts @@ -42,4 +42,13 @@ use(7); // comma spelling hands its left operand to the same statement lowering void (flag ? a() : b(), 0); +// `return` in a void function drops the value too; each branch completes +// the return, so trailing statements stay unreachable +function ret(f: boolean): void { + if (f) return deep ? a() : b(); + log += "tail"; +} +ret(true); +ret(false); + console.log(log);