Repository navigation
Description
This issue was drafted with the help of LLMs (Claude and Codex); I reviewed it and verified the reproductions.
Search Terms
logical assignment evaluated twice, ||= non-null assertion downlevel, ??= type assertion side effect, **= as Math.pow evaluated twice, PartiallyEmittedExpression logical assignment, ES2020 target logical assignment side effects
Version & Regression Information
- This is the behavior in every version I tried (4.0.2, 4.9.5, 5.9.3, 6.0.3, 7.0.2 and 7.1.0-dev.20261103.1), and I reviewed the FAQ for entries about type assertions and downlevel emit
Code
let key = 0;
const obj: Record<number, number> = {};
obj[++key]! ||= 1;
console.log(key, JSON.stringify(obj));Compiled with tsc --target es2020 input.ts, which reports no errors.
Actual behavior
"use strict";
let key = 0;
const obj = {};
obj[++key] || (obj[++key] = 1);
console.log(key, JSON.stringify(obj));node input.js prints 2 {"2":1}: ++key runs again in the assignment branch, and the value is written to a different property than the one that was read.
The same happens with &&= and ??= whenever the assignment branch runs, with as, satisfies and angle-bracket assertions around the target, and with an effectful receiver: getObject().x! ||= 1 calls getObject() twice. **= with --target es2015 has the same problem: obj[++key]! **= 2 is emitted as obj[++key] = Math.pow(obj[++key], 2).
Expected behavior
The ! is type-only and should not change evaluation: ++key should run once, and the output should print 1 {"1":1}, as node input.ts does with Node's type stripping. Without the !, the same file is emitted as obj[_a = ++key] || (obj[_a] = 1), and with --target es2021 the operator is kept as is; both print 1 {"1":1}.
Additional information about the issue
Steps, in an empty directory with Node.js 26.13.0:
npm install --save-exact typescript@7.0.2
npx tsc --target es2020 input.ts
node input.ts
node input.jsThe type eraser replaces !, as, satisfies and angle-bracket assertions with a PartiallyEmittedExpression (typeeraser.go#L263-L267). The logical assignment transform skips only parentheses before testing for an access expression (logicalassignment.go#L37-L41), so the test fails and the same left node becomes both the read and the write target (L96-L107). The exponentiation transform skips nothing before its element and property access tests and falls back to target = left and value = left (exponentiation.go#L37-L69). In the 6.0.3 JavaScript build, skipping partially emitted expressions together with parentheses at these two places (skipOuterExpressions(left, OuterExpressionKinds.Parentheses | OuterExpressionKinds.PartiallyEmittedExpressions)) makes every case above evaluate the target once; I have not built the Go compiler with the same change.
#39172 fixed the same duplication for plain element access targets in #39174, and that case still works. #44639 was the same kind of problem in optional chaining: a type assertion around the callee made the call lose this.
Activity