bastianplsfix

Loops

Four statement forms, and you rarely have to deliberate. for-of when you have a container of values, and this is the default. A plain for when you need an index, or a count that is not a container's length. while when the end condition is not a count at all. And for-in never, because it has three separate defects, and this page measures them so the advice stops being folklore. There is also for-await-of for asynchronous iterables, which is the same construct with one keyword added; the async iteration page takes it apart.

The choice is easy because these forms are less interchangeable than they look. for-of asks a value to hand over its contents, which only works if the value agreed to that in advance. A plain for counts, and knows nothing about containers. while does not even count.

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

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

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

while tests before each pass; do-while tests after

Two loops with the same false condition, one of each kind:

Deno.test("while tests before each pass; do-while tests after", () => {
const before: number[] = [];
let n = 10;
while (n < 5) {
before.push(n);
n++;
}
assertEquals(before, []);

const after: number[] = [];
let m = 10;
do {
after.push(m);
m++;
} while (m < 5);
assertEquals(after, [10]);
});
Check programs/loops.test.ts
running 1 test from ./programs/loops.test.ts
while tests before each pass; do-while tests after ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

The two arrays end up different, and the order of testing is the whole difference:

  1. while (n < 5) asks the question first. n is 10, so 10 < 5 is false, and the body never runs, which is why before is still [].
  2. do { ... } while (m < 5) runs the body first. after receives 10, and m becomes 11.
  3. Only then does do-while ask its question. 11 < 5 is false, so the loop stops, and after holds exactly one element.

Both conditions only have to be truthy, not boolean, which is the same rule if follows on the branching page, with the full list of falsy values in truthiness.

do-while is rare, and the honest description of when to reach for it is narrow: when the first pass has to happen before you can know whether to continue. Reading until a marker arrives, or retrying an operation at least once.

while is for an end you cannot count

Deno.test("while is for an end you cannot count", () => {
const queue = ["a", "b", "c"];
const drained: string[] = [];

while (queue.length > 0) {
drained.push(queue.shift()!);
}

assertEquals(drained, ["a", "b", "c"]);
assertEquals(queue, []);
});
while is for an end you cannot count ... ok (0ms)

The array shrinks while the loop runs, and the condition re-reads queue.length every pass, so nothing has to know the count in advance. That is what while is really for: an end you observe rather than an end you count down to.

The ! after queue.shift() is a non-null assertion. shift() removes and returns the first element, and its return type is string | undefined, because on an empty array there is nothing to return. The condition just proved the array is not empty, but the checker does not connect a length comparison to what shift() will find, so the ! tells it: this value is not undefined, I checked. It is a promise from you to the checker, and here the line above makes it true.

for is while with the moving parts at the top

Deno.test("for is while with the moving parts at the top", () => {
const viaFor: number[] = [];
for (let i = 0; i < 3; i++) viaFor.push(i);

const viaWhile: number[] = [];
let i = 0;
while (i < 3) {
viaWhile.push(i);
i++;
}

assertEquals(viaFor, viaWhile);
});
for is while with the moving parts at the top ... ok (0ms)

The same loop, twice, and the assertion proves they produce the same array. for exists so that the three moving parts sit together at the top rather than scattered: the declaration let i = 0, the condition i < 3, and the update i++. That placement matters because in the while spelling, the update lives at the bottom of the body, and the update at the bottom of the body is the line people forget.

All three parts are optional. Leave them all out and for (;;) loops forever, exactly like while (true). Both of those pass deno lint's recommended set with nothing said, because no-constant-condition is on by default and deliberately exempts loop conditions: an intentional infinite loop with a break inside it is a legitimate shape. That silence is an exemption for the shape, not an endorsement of writing while (1).

for-of hands over values; continue skips, break leaves

Deno.test("for-of hands over values; continue skips, break leaves", () => {
const lines = ["# heading", "Normal line", "# note", "Another normal line"];

const shown: string[] = [];
for (const line of lines) {
if (line.startsWith("#")) continue;
shown.push(line);
}
assertEquals(shown, ["Normal line", "Another normal line"]);

const upTo: string[] = [];
for (const line of lines) {
if (line === "# note") break;
upTo.push(line);
}
assertEquals(upTo, ["# heading", "Normal line"]);
});
for-of hands over values; continue skips, break leaves ... ok (0ms)

The variable receives each value, not each index, and that is most of why this is the form you want: no counter to manage, no lookup at the top of the body, just the element itself. The two keywords do different amounts of leaving. continue abandons the rest of the current pass and starts the next one, which is why the first loop still reaches every non-# line. break leaves the loop entirely, which is why the second loop never sees "Another normal line" even though it is not a # line.

for-of creates a fresh binding every pass

Deno.test("for-of creates a fresh binding every pass", () => {
const captured: (() => number)[] = [];
for (const value of [0, 1, 2]) captured.push(() => value);

assertEquals(captured.map((read) => read()), [0, 1, 2]);
});
for-of creates a fresh binding every pass ... ok (0ms)

const in a loop head looks like a contradiction, because the value differs each pass, and a const cannot be reassigned. The resolution is that nothing is reassigned: every pass creates a brand-new binding named value, initialized once, never written again. The three closures are the observable proof. Each one captured a different value, so reading them back gives [0, 1, 2] rather than three copies of the last element, and three different results means three different bindings existed. The closures page walks this exact mechanism in for...of gets the same per-iteration binding, and its the loop bug step shows what var used to do to this code.

So for (const ...) is not a style preference. It is the accurate declaration: within one pass, the variable never changes.

for (const i = 0; ...) is a shape that cannot exist

A plain for is the opposite case, because its update clause assigns to the variable. Try to have both:

Deno.test("for (const i = 0; ...) is a shape that cannot exist", () => {
const values: number[] = [];

for (const i = 0; i < 3; i++) values.push(i);

assertEquals(values, [0]);
});
Check programs/loops.test.ts
TS2588 [ERROR]: Cannot assign to 'i' because it is a constant.
for (const i = 0; i < 3; i++) values.push(i);
^
at file:///programs/loops.test.ts:76:30

error: Type checking failed.

The checker points at i++ rather than at the declaration, because the update is the illegal part: a plain for has one binding for the whole loop, and the update clause writes to it every pass. The linter refuses the same line at the same column, through no-const-assign:

error[no-const-assign]: Reassigning constant variable is not allowed
--> programs/loops.test.ts:76:30
|
76 | for (const i = 0; i < 3; i++) values.push(i);
| ^^^
= hint: Change `const` declaration to `let` or double check the correct variable is used

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

Pin both refusals to see what the language itself does, because const is not only a compile-time rule. The scope and declarations page measured that in const is enforced twice, and the same double enforcement holds in a for head:

Deno.test("for (const i = 0; ...) is a shape that cannot exist", () => {
const values: number[] = [];

assertThrows(
() => {
// @ts-expect-error: Cannot assign to 'i' because it is a constant.
// deno-lint-ignore no-const-assign
for (const i = 0; i < 3; i++) values.push(i);
},
TypeError,
"Assignment to constant variable.",
);

assertEquals(values, [0]);
});
for (const i = 0; ...) is a shape that cannot exist ... ok (1ms)

The final assertion is the interesting one. values holds [0], which is the loop caught halfway:

  1. const i = 0 runs once, and the condition 0 < 3 passes, so the body runs and pushes 0.
  2. i++ then tries to assign to a const, and the language throws a TypeError with the message the test pins: Assignment to constant variable.
  3. Nothing after that runs, so the array keeps the single element from the one completed pass.

So the previous step and this one are not in tension. for-of can use const because it makes a new binding every pass and never assigns to it. A plain for cannot, because it makes one binding and assigns to it forever. for (const ...) in a plain for is not a preference you are being denied; it is a shape that cannot exist.

for-of demands the iterable protocol by name

for-of works on arrays, so it is tempting to read it as an array feature. Point it at a plain object:

Deno.test("for-of demands the iterable protocol by name", () => {
const settings = { retries: 3, timeout: 50 };
const collected: number[] = [];

for (const value of settings) collected.push(value);

assertEquals(collected, []);
assertEquals(Object.entries(settings), [["retries", 3], ["timeout", 50]]);
});
Check programs/loops.test.ts
TS2488 [ERROR]: Type '{ retries: number; timeout: number; }' must have a '[Symbol.iterator]()' method that returns an iterator.
for (const value of settings) collected.push(value);
~~~~~~~~
at file:///programs/loops.test.ts:93:25

error: Type checking failed.

That error message is the best documentation the protocol has: the type must have a [Symbol.iterator]() method that returns an iterator, and that sentence is the entire requirement. Arrays are not privileged here; they qualify because they implement that method, and an object literal does not. Pin the refusal to hear the runtime's blunter version:

Deno.test("for-of demands the iterable protocol by name", () => {
const settings = { retries: 3, timeout: 50 };
const collected: number[] = [];

assertThrows(
() => {
// @ts-expect-error: Type '{ retries: number; timeout: number; }' must have a '[Symbol.iterator]()' method that returns an iterator.
for (const value of settings) collected.push(value);
},
TypeError,
"settings is not iterable",
);

assertEquals(collected, []);
assertEquals(Object.entries(settings), [["retries", 3], ["timeout", 50]]);
});
for-of demands the iterable protocol by name ... ok (0ms)

settings is not iterable, naming your variable instead of the rule. If you arrive from a language where looping over a dictionary hands you its entries, this is the surprise, and the last assertion is the answer: Object.entries turns the object into an array of [key, value] pairs, and an array is iterable. What implementing the protocol yourself involves is on the iterables and iterators page, and the result objects an iterator hands back already appeared on the sentinels page in iteration already made this decision.

for-in visits string keys, invited or not

Deno.test("for-in visits string keys, invited or not", () => {
const arr = ["a", "b"] as string[] & { note?: string };
arr.note = "added later";

const keys: string[] = [];
for (const key in arr) keys.push(key);

assertEquals(keys, ["0", "1", "note"]);
assert(keys.every((key) => typeof key === "string"));
});
for-in visits string keys, invited or not ... ok (0ms)

Three defects, measured in one array.

First, for-in visits keys rather than values, so every body starts with a lookup you did not want to write. Second, the keys are text: the array index arrives as "0", not 0, which is consistent, because a property key is always a string, as the conversion and coercion page measured in a property key is always text, and it is surprising every time anyway. Third, it visits every enumerable key, including anything somebody attached to the object later and anything inherited from the prototype: the note above is not an element of the array, and it turned up in the loop.

Deno ships a guard-for-in rule whose fix is wrapping the body in an if that filters inherited keys, which is the traditional defence, but the rule is off by default, and the better move is not to defend the construct at all. Object.keys, Object.values, and Object.entries each give you the own properties in a real array, and then for-of handles it with none of the three defects.

mutating an array mid-loop skips elements

The loop below removes an element from the front while walking. Predict visited:

Deno.test("mutating an array mid-loop skips elements", () => {
const items = ["a", "b", "c"];
const visited: string[] = [];

for (const item of items) {
visited.push(item);
if (item === "a") items.splice(0, 1);
}

assertEquals(visited, ["a", "b", "c"]);
assertEquals(items, ["b", "c"]);
});
Check programs/loops.test.ts
running 9 tests from ./programs/loops.test.ts
...
mutating an array mid-loop skips elements ... FAILED (8ms)

ERRORS

mutating an array mid-loop skips elements => ./programs/loops.test.ts:117:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"a",
+ "b",
"c",
]

FAILURES

mutating an array mid-loop skips elements => ./programs/loops.test.ts:117:11

FAILED | 8 passed | 1 failed (11ms)

error: Test failed

"b" was never visited. An array iterator holds a position, not a copy of the contents, and the two moved past each other:

  1. The iterator stands at position 0 and hands over "a", so visited receives it.
  2. items.splice(0, 1) removes "a" from the front, so "b" slides down into position 0 and "c" into position 1.
  3. The iterator advances to position 1 and finds "c" standing there, so "b", now behind the position, is stepped over.
  4. There is no position 2 anymore, so the loop ends, having processed two thirds of its input without a single error.

Correct the prediction to ["a", "c"]:

mutating an array mid-loop skips elements ... ok (0ms)

Nothing threw at any point, which is what makes this bug expensive: the program quietly does less work than it was given. This is also why the queue loop in while is for an end you cannot count is safe and this one is not. That loop re-reads queue.length every pass and removes from the same end it reads, so the position and the contents cannot disagree; here they can, and did. The rule to carry: do not add to or remove from a collection you are iterating. Build a new collection, or collect the changes and apply them after the loop.

a label names the statement break and continue mean

Deno.test("a label names the statement break and continue mean", () => {
const rows = [["a", "skip", "b"], ["c", "d"]];
const kept: string[] = [];

outer: for (const row of rows) {
for (const cell of row) {
if (cell === "skip") continue outer;
kept.push(cell);
}
}

assertEquals(kept, ["a", "c", "d"]);
});
a label names the statement break and continue mean ... ok (0ms)

A label sits in front of a statement, and break or continue with a label names which statement it means. Without the label, continue would mean the inner loop, so "b" would still be kept; with continue outer, the rest of the first row is abandoned, which is why "b" is missing from the result while the second row's "c" and "d" both survive.

a labelled block gives failure exactly one place

A label is not limited to loops. One can go in front of any statement, including a bare block, and that turns break into a structured jump out of the block:

Deno.test("a labelled block gives failure exactly one place", () => {
function findSuffix(names: string[], suffix: string): string {
let result: string;

search: {
for (const name of names) {
if (name.endsWith(suffix)) {
result = name;
break search;
}
}
result = "(untitled)";
}

return result;
}

assertEquals(findSuffix(["notes.md", "draft.txt"], ".txt"), "draft.txt");
assertEquals(findSuffix(["notes.md"], ".txt"), "(untitled)");
});
a labelled block gives failure exactly one place ... ok (0ms)

The break search skips the failure line, so success and failure each get exactly one place, and there is no boolean flag shuttling between them. Notice what the checker accepted: result is declared without a value, and the return is still allowed, because the checker followed the control flow and proved that every path through the block assigns result before the return reads it.

That is the honest case for a label, and it is also why labels stay rare: the same job is usually done by a small function and an early return, so a reader who meets a label should ask whether a function was wanted. What you will actually run into is the leftover, where somebody did exactly that refactor, replaced the break with a return, and the label stayed behind. Paste this after the test's closing }); to see it reported:

export function firstMatch(rows: string[][], needle: string): string {
search: for (const row of rows) {
for (const cell of row) {
if (cell === needle) return cell;
}
}
return "";
}
error[no-unused-labels]: `search` label is never used
--> programs/loops.test.ts:167:3
|
> 167 | search: for (const row of rows) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 168 | for (const cell of row) {
> 169 | if (cell === needle) return cell;
> 170 | }
> 171 | }
| ^

docs: https://docs.deno.com/lint/rules/no-unused-labels

no-unused-labels is in Deno's recommended set, so the leftover reports itself, and the fix is deleting the label, not the loop. Delete the whole function again before moving on, so the file stays clean.

.entries() keeps the index inside for-of

Deno.test(".entries() keeps the index inside for-of", () => {
const pairs: string[] = [];

for (const [index, value] of ["a", "b"].entries()) {
pairs.push(`${index} -> ${value}`);
}

assertEquals(pairs, ["0 -> a", "1 -> b"]);
});
.entries() keeps the index inside for-of ... ok (0ms)

This is the answer to "but I need the index" that keeps you inside for-of. .entries() returns an iterable of [index, element] pairs, and the square brackets in the loop head are not an array literal: they unpack each pair into two names, the pattern the destructuring page takes apart. Note that the index arrives as the number 0 here, not the string "0" that for-in produced, because .entries() hands over values it built, not property keys. Reach for a plain for only when there is no collection to ask.

strings, Maps, and Sets implement the same protocol

Deno.test("strings, Maps, and Sets implement the same protocol", () => {
assertEquals("a๐Ÿ™‚".length, 3);
const characters: string[] = [];
for (const character of "a๐Ÿ™‚") characters.push(character);
assertEquals(characters, ["a", "๐Ÿ™‚"]);

const entries: string[] = [];
for (const [key, value] of new Map([["retries", 3]])) {
entries.push(`${key}=${value}`);
}
assertEquals(entries, ["retries=3"]);

const members: string[] = [];
for (const member of new Set(["x", "x", "y"])) members.push(member);
assertEquals(members, ["x", "y"]);
});
strings, Maps, and Sets implement the same protocol ... ok (0ms)

The protocol from for-of demands the iterable protocol by name pays off in every direction, because each container decides what its contents are. A string iterated with for-of yields code points, not the UTF-16 units that .length counts, which is why the loop produced two characters where .length reported three; the text and characters page measured that split in iteration gives you code points. A Map yields [key, value] pairs, ready for the same destructuring as .entries(). A Set yields its members, and the duplicate "x" is already gone because a Set never held it twice. None of this is special-cased inside the loop. It is one method, [Symbol.iterator](), implemented three ways.

.forEach() cannot stop

.forEach() is everywhere, so it earns an honest measurement. The callback below returns early on "b", so predict seen:

Deno.test(".forEach() cannot stop", () => {
const seen: string[] = [];

["a", "b", "c"].forEach((value) => {
if (value === "b") return;
seen.push(value);
});

assertEquals(seen, ["a"]);
});
Check programs/loops.test.ts
running 14 tests from ./programs/loops.test.ts
...
.forEach() cannot stop ... FAILED (8ms)

ERRORS

.forEach() cannot stop => ./programs/loops.test.ts:192:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"a",
- "c",
]

FAILURES

.forEach() cannot stop => ./programs/loops.test.ts:192:11

FAILED | 13 passed | 1 failed (11ms)

error: Test failed

"c" is in the array, because the return did not end the loop. It ended one call: the loop belongs to .forEach(), and your code is a function that .forEach() calls once per element, so return hands control back to the method, and the method simply makes the next call. It skipped one element, like a continue, when the prediction wanted a break, and there is no break available at all, because break must be written inside a loop statement and the callback is not inside one. Correct the prediction to ["a", "c"]:

.forEach() cannot stop ... ok (0ms)

So the division is clean. for-of with break is the version that can stop early, and it is also the version where await behaves, since await inside the callback would be inside a different function from the one doing the looping. Reach for .forEach() only when you genuinely want every element and nothing else.

In practice

Related