Skip to content

fix: compile void ternaries in discarded-value positions - #204

Open
not-the-ccp wants to merge 2 commits into
vercel-labs:mainfrom
not-the-ccp:fix/void-ternary-statement
Open

fix: compile void ternaries in discarded-value positions#204
not-the-ccp wants to merge 2 commits into
vercel-labs:mainfrom
not-the-ccp:fix/void-ternary-statement

Conversation

@not-the-ccp

@not-the-ccp not-the-ccp commented Aug 22, 2026

Copy link
Copy Markdown

Fixes #33

Note: this overlaps in intent with #72 (same issue, opened earlier). #72 rewrites conditionals into if statements before generic lowering; this PR instead lowers through the existing lowerTernary machinery first and reshapes the IR afterwards, which preserves constant-condition folding and the narrowing bridges by construction. Happy to consolidate whichever direction maintainers prefer.

Problem

A conditional expression over two void calls tripped SC9001: internal compiler error: in %fn0: ternary must not be void — please report this in every position — including the ones the SC1090 'void'-in-value-position hint explicitly advertises as supported:

function a(): void { console.log("a") }
function b(): void { console.log("b") }
const flag = process.argv.length > 99;

// 1. statement position
flag ? a() : b();

// 2. void-returning concise arrow body (the shape in #33)
const pick = (n: number) => (flag ? a() : b());

// 3. return from a void-returning function
function f(): void {
  return flag ? a() : b();
}

The validator correctly fences void ternaries as IR expressions (there is no void value to select between), but the frontend kept producing them in positions where JavaScript discards the value anyway.

Fix

At a discarded-value site, the exact semantics are "evaluate the condition, run only the taken arm, drop both values" — which is if/else over the same lowered pieces. The discarded-value landings now reshape the already-lowered IR:

  • lowerExprStatement's fallback (statement position, including the void e and comma spellings that hand operands to the same lowering),
  • both concise-arrow-body paths returning void (generic instantiation + lowerLambda),
  • return e; inside a void-returning function — each branch of the reshape carries its own explicit bare return, so trailing statements stay unreachable.

via a new voidTernaryIfStmt / voidTernaryIfStmtOrExprStmt helper in lower-stmts.ts. The rewrite recurses, so nested void ternaries (deep ? (flag ? a() : b()) : b()) reshape into nested ifs.

Because the reshape happens after lowering, everything lowerTernary already does is preserved: constant-condition folding, typeof/optional narrowing bridges, laziness of the untaken arm.

A consumed void ternary still has no IR representation, but it now gets a pointed fence instead of the ICE:

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

The fence is gated on a stateless parent walk (discardedVoidTernarySite, same style as inLogicalLeftPosition) that recognizes exactly the discard sites: expression statements (through parens, void e, and commas), concise bodies of void-returning arrows, return statements whose function's return type maps to void, and arms of enclosing ternaries that themselves join to void (those are the ones the reshape rewrites). Non-void returns keep their existing SC0001 assignability diagnostic, which fires before lowering either way.

Testing

  • tests/corpus/2692-void-ternary-statement.ts — differential program pinning the compiled behavior against Node byte-for-byte: statement position, arm laziness, nesting, concise/generic arrow bodies, narrowing into arms, comma spelling, and void-return statements with unreachable trailing code.
  • tests/diagnostics/void-ternary-value-position.ts — snapshots both remaining fence shapes (direct value position; void arm under a non-void sibling).

Locally: full plain differential lane (1087 programs) and LLVM differential lane pass except four pre-existing environment failures (1423-text-codec, 1640-fd-read-decode, 2124-imports-field-wildcard, 2599-stream-arg-ladders — verified identical on a clean checkout), diagnostics corpus 115/115, smoke/deadstrip/library suites green (two pre-existing ASan environment failures excluded, also verified on clean checkout). Lint clean.

)

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 vercel-labs#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 vercel-labs#33
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

@not-the-ccp is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SC9001 internal compiler error: 'ternary must not be void' for an arrow body over two void calls

1 participant