Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/compiler/src/frontend/lowering/lower-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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) });
}
Expand Down Expand Up @@ -5815,7 +5816,9 @@ const inliningPredicates = new Set<ts.Symbol>();
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);
Expand All @@ -5828,7 +5831,7 @@ const inliningPredicates = new Set<ts.Symbol>();
}
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",
Expand Down
66 changes: 66 additions & 0 deletions packages/compiler/src/frontend/lowering/lower-exprs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -2201,6 +2215,58 @@ 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;
// `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;
}
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
Expand Down
32 changes: 32 additions & 0 deletions packages/compiler/src/frontend/lowering/lower-stmts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion packages/compiler/src/frontend/lowering/lowerer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions tests/corpus/2692-void-ternary-statement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// 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 = <T,>(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);

// `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);
17 changes: 17 additions & 0 deletions tests/diagnostics/void-ternary-value-position.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions tests/harness/__snapshots__/void-ternary-value-position.ts.txt
Original file line number Diff line number Diff line change
@@ -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 |