The actual scope is determined by the interpreter / runtime which ideally correctly implements sufficiently precise language specs (in this case see here).
The latter tell you that in 'for' loops, the scope of declarations in the initializer section is a 'virtual' block encompassing the overt block of the for loop. However, it gets a bit more complicated: for each iteration, the binding of declarations is renewed.
You could rewrite the code as follows to elicit equivalent behavior:
let i=42;
{
let i_shadow = 0;
while (i_shadow < 3) {
let i = i_shadow;
setTimeout(function() { console.log(`'i' written through 'setTimeout': ${i}`) }, 100);
i_shadow++;
}
console.log(`'i' inside virtual block: ${i_shadow}.`);
}
console.log(`'i' outside virtual block: ${i}.`);
The interpreter/runtime 'knows' about that behavior as the context o a 'for' loop has been lexically established at the time the declaration of i
is encountered (*).
Compare to the original:
for (let i = 0; i < 3; i++) {
setTimeout(function() { console.log(i) }, 100);
}
console.log(`'i' outside virtual block: ${i}.`);
You may also want to have a look at this SO answer.
(*)
Note that in principle (ie. for other languages), the establishment of the lexical context at this position is no necessity: it all depends on the language semantics from the specs and their embodiment in compilers/interpreters and the runtime.