Skip to content

Downlevel emit of ||=, &&=, ??= and **= evaluates a member target twice when it has !, as or satisfies #65287

Closed

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.js

The 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.

Metadata

Metadata

Labels

BugA bug in TypeScript

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions