bastianplsfix

Assignment

Assignment puts a value into a storage location. There are three families of it: plain (=), compound (+= and its relatives), and logical (||=, &&=, ??=). The first two always write. The third may decline: a ??= b performs no assignment at all when a already holds something. That difference is invisible most of the time and matters enormously in the places where a write is watched, which is exactly what this entry builds a witness to observe.

Create programs/assignment.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

three kinds of location, one act

Three kinds of location can be assigned to, and they read the same way. The recorder helper at the top of the file is that witness; it sits idle until the logical three skip the write.

function recorder(initial: string | null) {
const writes: string[] = [];
const store = {
hidden: initial,
get title(): string | null {
return this.hidden;
},
set title(value: string | null) {
writes.push(String(value));
this.hidden = value;
},
};
return { store, writes };
}

Deno.test("three kinds of location, one act", () => {
let total = 0;
const receipt = { total: 0 };
const amounts = [0, 0];

total = 10;
receipt.total = 10;
amounts[1] = 10;

assertEquals(total, 10);
assertEquals(receipt.total, 10);
assertEquals(amounts, [0, 10]);
});
Check programs/assignment.test.ts
running 1 test from ./programs/assignment.test.ts
three kinds of location, one act ... ok (1ms)

ok | 1 passed | 0 failed (2ms)

A variable, a property, and an array slot, each receiving 10 the same way. Three orientation notes before the depths.

  1. A declaration with an initializer is the same act spelled once: const receipt = { total: 0 } declares and assigns together, which is why the scope and declarations page treats declaration and assignment as separate questions.
  2. const restricts the first line of the group and not the second: the binding is fixed, the contents are not, from const locks the name, not the contents on the values-and-references page.
  3. What actually travels depends on the value. A primitive is copied; an object is not, and both names then reach the same object. That is the whole subject of the values entry, and the single most common surprise in assignment.

assignment is an expression

An assignment produces a value, which is why assignments can chain:

Deno.test("assignment is an expression", () => {
let a = 1;
let b = 2;
const result = (a = b = 9);
assertEquals(result, 9);
assertEquals(a, 9);
assertEquals(b, 9);
});
assignment is an expression ... ok (0ms)

b = 9 evaluates to 9, which flows into a, which evaluates to 9 again and lands in result. Useful to know, rarely worth writing, and its main practical consequence is a trap: if (x = 1) is legal, and assigns rather than compares. The checker accepts it cleanly, because an assignment in a condition is not a type error. The linter is the tool that objects, and no-cond-assign is in Deno's recommended set:

error[no-cond-assign]: Expected a conditional expression and instead saw an assignment
--> programs/assignment.test.ts:2:5
|
2 | if (x = 1) {
| ^^^^^
= hint: Change assignment (`=`) to comparison (`===`) or move assignment out of condition

docs: https://docs.deno.com/lint/rules/no-cond-assign

This is worth knowing as a pattern rather than a single rule: the checker reasons about types, and a mistyped = for === is not a type error. Some classes of bug belong to the linter, and running only one of the two tools leaves them unreported. The parentheses around (a = b = 9) follow the same logic from the other side: writing an assignment where a value is expected should look deliberate.

compound assignment expands to its operator

For every operator except the three logical ones, x op= v means exactly x = x op v:

Deno.test("compound assignment expands to its operator", () => {
let total = 10;
total += 5;
assertEquals(total, 15);
total *= 2;
assertEquals(total, 30);
});
compound assignment expands to its operator ... ok (0ms)

total += 5 reads the current value, applies the operator, and writes the result back: 15, then doubled to 30. The full set is larger than most people use: arithmetic (+=, -=, *=, /=, %=, **=), bitwise (&=, ^=, |=, <<=, >>=, >>>=), and the logical three, which the next step shows are not an expansion at all.

the logical three skip the write

Now the recorder earns its keep. Its title property routes every write through a setter that logs to writes, so the array is a perfect record of how many assignments actually happened. First the spelled-out idiom as a baseline, then ||= with a prediction: if a ||= b means a = a || b, the second recorder's log should match the first. Save:

Deno.test("the logical three skip the write", () => {
const noisy = recorder("Home");
noisy.store.title = noisy.store.title || "(Untitled)";
assertEquals(noisy.writes, ["Home"]);

const present = recorder("Home");
present.store.title ||= "(Untitled)";
assertEquals(present.writes, ["Home"]);
});
Check programs/assignment.test.ts
running 4 tests from ./programs/assignment.test.ts
three kinds of location, one act ... ok (0ms)
assignment is an expression ... ok (0ms)
compound assignment expands to its operator ... ok (0ms)
the logical three skip the write ... FAILED (8ms)

ERRORS

the logical three skip the write => ./programs/assignment.test.ts:50:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

+ [
+ "Home",
+ ]
- []

FAILURES

the logical three skip the write => ./programs/assignment.test.ts:50:11

FAILED | 3 passed | 1 failed (10ms)

error: Test failed

The second log is empty. Walk both halves.

  1. noisy.store.title = noisy.store.title || "(Untitled)" read "Home", found it truthy, and assigned "Home" over "Home": a pointless write, but a real one, and the setter recorded it.
  2. present.store.title ||= "(Untitled)" is not a = a || b. It is a || (a = b): when the left side is already truthy, no assignment happens at all, so the setter never ran.

Where that matters is anywhere a write is more than a write. A setter runs. A proxy traps. A reactive framework marks the value dirty and re-renders. A database layer marks the row changed. In all of those, the spelled-out idiom produces work and the operator produces none, and none is what you meant. Correct the prediction to [], and add the case where a write genuinely must happen:

Deno.test("the logical three skip the write", () => {
const noisy = recorder("Home");
noisy.store.title = noisy.store.title || "(Untitled)";
assertEquals(noisy.writes, ["Home"]);

const present = recorder("Home");
present.store.title ||= "(Untitled)";
assertEquals(present.writes, []);
assertEquals(present.store.title, "Home");

const absent = recorder(null);
absent.store.title ??= "(Untitled)";
assertEquals(absent.writes, ["(Untitled)"]);
assertEquals(absent.store.title, "(Untitled)");
});
the logical three skip the write ... ok (0ms)

The absent recorder starts at null, so ??= has real work to do, does it once, and the log shows exactly one write.

the right-hand side is skipped as well

The store is not the only thing spared. Plant a second witness on the fallback itself:

Deno.test("the right-hand side is skipped as well", () => {
const calls: string[] = [];
function expensive(tag: string): string {
calls.push(tag);
return tag;
}

let kept: string | null = "already here";
kept ||= expensive("skipped");
assertEquals(calls, []);
assertEquals(kept, "already here");

let empty: string | null = null;
empty ||= expensive("paid for");
assertEquals(calls, ["paid for"]);
assertEquals(empty, "paid for");
});
the right-hand side is skipped as well ... ok (0ms)

kept was already truthy, and calls proves expensive never ran: the right-hand side was never evaluated, the same short-circuiting the truthiness page measured in the conditional operator runs only the branch it picks. So the fallback can be as expensive as you like, and it is only paid for when it is used.

the three differ only in their question

The three operators share the skipping machinery and differ only in the question they ask about the current value:

OperatorAssigns when the left side isAsk this when
||=falsyany of the eight falsy values should go
&&=truthyyou want to transform a value that is present
??=null or undefinedabsence is what you are replacing
Deno.test("the three differ only in their question", () => {
let zero: number | null = 0;
zero ||= 7;
assertEquals(zero, 7);

let alsoZero: number | null = 0;
alsoZero ??= 7;
assertEquals(alsoZero, 0);

const notes: Record<string, string | undefined> = { draft: " draft " };
let note = notes.draft;
note &&= note.trim();
assertEquals(note, "draft");

let missing = notes.absent;
missing &&= missing.trim();
assertEquals(missing, undefined);
});
the three differ only in their question ... ok (0ms)

Walk the four cases.

  1. zero ||= 7 replaced a configured 0 with 7, because 0 is falsy. This is the same zero-eating that ?? treats only null and undefined as missing measured on the nothing-twice page, now with an assignment attached.
  2. alsoZero ??= 7 kept the 0, because 0 is not null or undefined. For the same reason ?? beats ||, ??= is almost always the one you want: a zero, an empty string, and false are data, not absence.
  3. note &&= note.trim() transformed a value precisely because one was there. Inside the right-hand side the checker has already narrowed note to string, so .trim() needs no guard.
  4. missing &&= missing.trim() did nothing at all: the left side was undefined, so the right side never evaluated, which is the only reason .trim() on a missing value did not throw.

&&= reads strangely at first, since it assigns exactly when a value is present. Conditional transformation is its job, and the trim is the honest example.

??= fills in what is missing

The pattern ??= exists for, one line per field:

Deno.test("??= fills in what is missing", () => {
const books: { title?: string }[] = [
{ title: "Dune" },
{},
];
for (const book of books) {
book.title ??= "(Untitled)";
}
assertEquals(books, [{ title: "Dune" }, { title: "(Untitled)" }]);
});
??= fills in what is missing ... ok (0ms)

No branch, no spelled-out check, and the record that already had a title was not rewritten with the value it already held. After the logical three skip the write, that last clause is a guarantee rather than a hope: untouched records stay untouched.

+= inherits both jobs of +

The conversion-and-coercion page's + joins when either side is text established that + has two jobs. += inherits both, and the checker cannot save you, because mixing a string with a number is legal JavaScript typed as a string. Predict the result:

Deno.test("+= inherits both jobs of +", () => {
let text = "0";
text += 1;
assertEquals(text, "1");
});
Check programs/assignment.test.ts
running 8 tests from ./programs/assignment.test.ts
...
+= inherits both jobs of + ... FAILED (9ms)

ERRORS

+= inherits both jobs of + => ./programs/assignment.test.ts:114:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- 01
+ 1

FAILURES

+= inherits both jobs of + => ./programs/assignment.test.ts:114:11

FAILED | 7 passed | 1 failed (11ms)

error: Test failed

"01": joined, not added. text is a string, so + took its joining job, and the checker allowed every character of it, because string += number is well-typed and produces a string. Correct the prediction to "01":

+= inherits both jobs of + ... ok (0ms)

This is the one place in the entry where nothing protects you, not the checker and not the linter, so it is worth recognizing by sight: += on anything that might be text deserves a second look.

what the checker learns from ??=

One more gift from the operator, this time to the reading rather than the running. A nullable value assigned directly to a string variable is refused:

Deno.test("what the checker learns from ??=", () => {
let note: string | null = null;
const definite: string = note;
assertEquals(definite, "none");
});
Check programs/assignment.test.ts
TS2322 [ERROR]: Type 'null' is not assignable to type 'string'.
const definite: string = note;
~~~~~~~~
at file:///programs/assignment.test.ts:122:11

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

One ??= line discharges the nullable, with no cast and no branch:

Deno.test("what the checker learns from ??=", () => {
let note: string | null = null;
note ??= "none";
const definite: string = note;
assertEquals(definite, "none");
});
what the checker learns from ??= ... ok (0ms)

After note ??= "none", the checker knows note cannot still be null, because the assignment happens in exactly the case that would have left it so. The narrowing from the unions and narrowing page survives the operator, which makes ??= a tidy first line for any function that accepts a nullable.

One neighbor this entry leaves alone: destructuring is a second syntax for assignment, pulling several values out of an array or object in one statement, with its own rules for defaults, renaming, and rest elements. It is large enough to deserve its own reference, destructuring, rather than a corner of this one.

In practice