Skip to content(if available)orjump to list(if available)

On JavaScript's Weirdness

On JavaScript's Weirdness

84 comments

·April 3, 2025

pcthrowaway

Many mistakes in section 2. The author seems to fundamentally misunderstand block scoping vs lexical scoping, and interactions when deferring execution to the next run of the event loop.

In the first example:

    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i));
    }
    // prints "0 1 2" — as expected
    
    let i = 0;
    for (i = 0; i < 3; i++) {
      setTimeout(() => console.log(i));
    }
    // prints "3 3 3" — what?
i's scope is outside the for loop in the second example, and the setTimeouts execute in the call stack (e.g. the next run of the event loop), after i has finished incrementing in the first event loop iteration

Consider that you'd have the same issue with the older `var` keyword which is lexically scoped

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i));
    }
    // prints "3 3 3" because i is not block-scoped
If for some reason you really need some work run in the next call stack, and you need to use the value of a variable which is scoped outside the loop and modified inside the loop, you can also define a function (or use an iife) to pass the value of i in the current iteration into (rather than getting the reference of i in the event loop's next call stack)

    let i = 0;
    for (i = 0; i < 3; i++) {
      (
        (x)=>setTimeout(()=>console.log(x))
      )(i)
    }
    // prints 1 2 3

IshKebab

This sort of stuff is very explicit and unsurprising in C++ (and to a lesser extent Rust), but it's always confusing in languages that leave the capturing details implicit. Even Go got bitten by this and it doesn't even JavaScript's broken `var`.

sapiogram

I don't think it's fair to call Go and Javascript's behavior "implicit", they just always capture variables by reference.

Rust variable capture is implicit though, but it can't cause the problems described in the article, since mutable references are required to be unique.

_old_dude_

In JavaScript, a 'let' inside the initializer of a for loop is captured by value, all the others are captured by reference.

I think it's fair to call that semantics "implicit".

bryanrasmussen

the argument is about things that are weird, any effect in a language that means you have to stop and think over scoping rules to figure out why it should be that way is obviously "weird" to my understanding of this word.

In short I'm not sure that they have misunderstood the scoping, they have probably understood it fine, they have remarked on the weirdness that different aspects of JavaScript enables.

Certainly with perfect understanding and knowledge of a language that you do not have to think about at all because it is so perfectly remembered nothing would ever be weird, it is the incidental behaviors of the language at time where you have to stop and think hey why is that, oh yeah, scoping rules and timeout in the call stack, damn!

n2d4

Yes, it's about block scoping — but that doesn't make it less weird. In most languages this doesn't really make sense — a variable is a piece of memory, and a reference refers to it. JavaScript doesn't work like that, and that's weird to many.

What's the mistake that I made there? I just didn't explain why it happens. I briefly mentioned this in the later paragraphs — it makes sense to some people, but not to most.

rafram

JavaScript does work like that, but `for` creates a new block scope for each iteration, so variables declared with `let` in its initializer are redeclared each time. Some other languages ([1]) just make accessing mutable locals from a closure into a compiler error, which I think is also reasonable. Old-school JavaScript (`var`s) chose the worst-of-both-worlds option.

[1]: https://stackoverflow.com/q/54340101

pcthrowaway

OK so for one the title of that section is off:

> JS loops pretend their variables are captured by value

This has to do with how for loops work with iterators, but also what `let` means in variable declaration. You talk about 'unrolling a for loop' but what you're doing is 'attempting to express the same loop with while'. Unrolling would look like this;

    // original:
    for (let i = 0; i < 3; i ++) { setTimeout(()=>console.log(i)) }
    // unrolled:
    { let i = 0; setTimeout(()=>console.log(i)) };
    { let i = 1; setTimeout(()=>console.log(i)) };
    { let i = 2; setTimeout(()=>console.log(i)) };

    // original:
    let i = 0;
    for (i = 0; i < 3; i++) { setTimeout(()=>console.log(i)) };
    // unrolled:
    let i = 0;
    { i = 0; setTimeout(()=>console.log(i)); };
    { i = 1; setTimeout(()=>console.log(i)); };
    { i = 2; setTimeout(()=>console.log(i)); };
Now you can begin to explain what's going wrong in the second example; 'i' is declared with 'let' outside of the block, and this means the callback passed to the setTimeout is placed in the next stack frame, but references i from the outer scope, which is modified by the time the next stack frame is running.

In the original example, a different 'i' is declared inside each block and the callback passed to setTimeout references the 'i' from its scope, which isn't modified in adjacent blocks. It's confusing that you're making this about how loops work when understanding what the loop is doing is only one part of it; understanding scoping and the event loop are 2 other important pieces here.

And then if you're going to compare a while loop to a for loop, I think a critical piece is that 'while' loops (as well as 'do .. while') take only expressions in their condition, and loop until the expression is false.

'for' loops take three-part statements, the first part of which is an initialization assignment (for which 'var' and 'let' work differently), and the second of which is an expression used as the condition. So you can declare a variable with 'let' in the initialization and modify it in the 'afterthought' (the third part of the statement), but it will be treated as if each iteration of the loop is declaring it within the block created for that iteration.

So yes, there are some 'for' loop semantics that are specific to 'for' loops, but rather than explain that, you appear to be trying to make a point about loops in general that I'm not following.

I'm not saying the examples won't help people avoid pitfalls with for and while loops, but I do think they'll be unable to generalize any lessons they take away to other situations in JS, since you're not actually explaining the principles of JS at play.

n2d4

I mentioned that the title makes no sense in the sentence right after it:

> Yes, the title makes no sense, but you'll see what I mean in just a second.

And yes, I didn't explain the exact mechanics of the ES spec which make it happen — but I would argue that "variables can be modified until they're out-of-scope" is even more unintuitive than just remembering this edge case. And I'm not trying to be an ECMAScript lawyer with the post, rather I'd just show a bunch of "probably unexpected" behaviors of JavaScript.

Cyykratahk

For anyone who wants to see some more explanations/experimentations around for loop semantics, this Chrome Developers video is great:

https://www.youtube.com/watch?v=Nzokr6Boeaw

sapiogram

The author's explanation seems perfectly correct to me. Where does he "misunderstand block scoping vs lexical scoping"? By the Wikipedia definition:

> lexical scope is "the portion of source code in which a binding of a name with an entity applies".

...both `let` and `var` are lexically scoped, the scopes are just different.

Leszek

FWIW, I think the parent meant "function scoping vs lexical scoping" rather than "block scoping vs lexical scoping". You're correct that function scoping is technically a form of lexical scoping (where the scope is the function), but if you want to be _really_ pedantic, the ecma262 considers let/const to be "lexical binding"[0] as opposed to var being a "variable declaration"[1], where the former declares in the "Lexical Environment" while the latter declares in the "Variable Environment". These happen to be the same environment record on function entry.

[0] https://tc39.es/ecma262/#sec-let-and-const-declarations [1] https://tc39.es/ecma262/#sec-variable-statement

sapiogram

Thanks for the links, that adds a lot of important context.

masswerk

However, there's no notion of the first example operating on a single scope and the latter on three different, individual scopes. Which is why scope ranges and where you declare a variable with `let` matters.

glenjamin

Complaining about the for loop behaviour seems odd. Variables declared in the expression section of the loop are scoped to the loop body - this is generally reasonable and the least likely to produce errors.

Notably, Go initially decided to stick with the C-style approach and have the scope be outside the loop, and has since decided that the tiny performance improvement this provides isn't worth the many many times it's tripped people up, and has changed the semantics in recent versions: https://go.dev/blog/loopvar-preview

Joker_vD

Go's behaviour never made much sense because, unlike JS/C#/Java, Go has pointers. So if you really want to capture the variable itself and not its current value at the specific iteration, you can (and should) just capture the pointer to it.

But even in C#/Java/JS, it never made much sense either since a) you almost always want to capture the current value, not variable itself; b) if you really want the variable itself, you can put it into a 1-element array and capture the array (which how people evade Java's requirement that the captured variables must be "final" anyway) and use arr[0] everywhere instead.

brap

I remember properly learning JS from “The Good Parts” book, which makes it known from the start that JS is a nasty language but if you ignore many sharp edges it can be nice and elegant. I think this is especially true with (a subset of) TS. All you need is a very, very strict linter, and then you get to pretend you’re working in a mostly solid language.

kyle-rb

I didn't read JS The Good Parts until it was well outdated, and I was glad to see that a lot of the sharp edges that Crockford lists have largely been eliminated. The book was written circa ES3, so some problematic features were removed in strict mode, we have replacements for some (let/const, for-of loops, etc), and we can sweep prototypes under the rug with ES6 classes, sometimes arrow functions even avoid awkward this-binding issues. The rest, like you said, TypeScript + a linter takes care of.

nobleach

I find these oddities far more realistic than the Wat video from a long time ago. Many of the things in that video had me asking, "sure but...what programmer would blindly try these things and then be shocked when they didn't work?" The examples in this article are actual "gotchas" that could silently bite someone.

troupo

wat video was intended to be funny and tongue in cheek.

Leszek

The eval thing is listed as a potential performance cost, but it's actually super important for performance, because it allows the parser to statically know that sloppy eval is never called inside a function, and that variables can therefore be optimized away.

vanderZwan

The list of "other weirdness" at the end mentions:

> +0 vs. -0

Which feels kind of odd, since that's mostly floating point weirdness, not JS weirdness. Unless they mean the fact that you can force V8 to initialize a zero-value as a double using -0 as a literal (it tends to optimize 0 to integers). But that has no effect on real-world code unless you use Math.sign, or divide by minus zero.

masswerk

Fun fact: JavaScript 1.2 did feature -0 and 0 = +0. Which has quite a number of rather confusing aspects and effects, if you run a script in this context.

smjburton

While interesting and possibly helpful to new coders, are these quirks of the language still relevant when most development in Javascript is done using a framework (React, Vue, etc) these days? How often do these "gotchas" factor into "modern" Javascript development, especially in production? These type of articles seem to critique mechanics of the language that don't come up as often in practice.

mubou

The issue with variables and loops that OP described is worse with React, since you create closure inside the render function for event handlers, and if that closure captures any state variables (rather than a getter function for example), then you'll end up referencing stale state. React relies on linters to protect against this, but that only goes so far and the API design makes it easy to screw up, so you have to be on your toes.

Edit: To be clear, this is specifically a React hooks problem, not the old React with classes.

smjburton

That's a fair point. Adding that to the original post would help provide context about why some of these quirks are still relevant to consider even when using a framework. I believe the assumption is often that frameworks abstract the Javascript "weirdness" away.

DecoySalamander

Quirks in this article and others like it are not something that would be encountered under normal circumstances unless the programmer is doing something silly (like skipping semicolons, ever touching eval, or relying on type conversions). Now, second point about loops (actually scopes and value declaration) is a core part of the language and needs to be learned, framework or no framework.

markussss

You are right, these quirks are not something you struggle with very often. The only one that has been troublesome at some point during my now 8 years as a professional, mainly Javascript with Vue, web developer is the automatic semicolon insertion example.

The simplest fixes for it is to just insert semicolons yourself, always use const, and not start any lines with [ or (.

lelanthran

It's worse in a framework, in the framework you need to know the oddities of the language as well as how the framework manages them.

machine_ghost

What I don't understand is why, after twenty years, we still haven't versioned Javascript. A simple:

'v2';

At the top of every file could let us eliminate all this 20-year old cruft (like document.all hacks to support Internet Explorer).

Yet, despite the already established `use strict`; (which is basically 'v1.5'), the community seems completely against modernizing the language.

null

[deleted]

jmull

Variable declared outside the loop construct lives outside the loop.

Variable declared inside the loop construct lives inside the loop.

Seems intuitive to me.

The complex thing here (and what seems to have confused the author) is the distinction between when the reference to the variable is captured vs. when the setTimeout() callback occurs.

Actually, this article shows what good shape Javascript is in. As it says, commonly deployed linters catch the really bad stuff, effectively deprecating those things.

Pretty much the rest of it to do with specific domains outside of javascript, like what actually is a character and IEEE floating point, or are rather out-of-the-way things like document.all and sparse arrays (not that people don't use sparse arrays, but it's entirely optional and if you're going to voluntarily go into that cave, I guess you must be happy to tangle with the bears living there.)

blatantly

I'd forgive a few of those. Unicode is Unicode. The for loop capture behaviour makes sense to me. Missing semis should also be in your linter. Sparse arrays is the sort of feature you'd read up on if you use and not rely on intuition. It makes sense that if you loop over a sparse thing the looping is sparse too.

chuckadams

Since I started using Prettier, I've moved permanently into the no-semicolons camp. Prettier catches ASI hazards and inserts semicolons where needed, and I've never seen it fail. Whichever camp you're in though, put it in your linter, don't leave it to chance. React code is full of array destructuring, that particular hazard is prone to bite you if you ignore it (tho it's still a little contrived if your usual style is avoiding mutable variables).

hombre_fatal

I can't think of any typical case where you're destructuring arrays in React without const/let.

The only time you start a line with a delimiter in JS that I can think of is a rare case like `;[1,2,3].forEach(...)` which also isn't something you do in React.

While I still hate semis, these days my approach to formatting is just `echo {} > .prettierrc` and using the defaults. It's a nice balance where I never write a semicolon myself, and I never have to dick around with config.

nsonha

The rules are not that many, you can omit semicolons everywhere except 1. Before open square bracket 2. Before open parenthesis.

That's it, those are the only 2 edge cases.

bakkoting

No, there are quite a lot of other edge cases. E.g. you also need them before backticks and in many places in class bodies.

lelanthran

Instead of learning a rule and then memorizing exceptions to it, you could just learn a rule with no exceptions.

epmatsw

And yet, per the spec, new syntax features are allowed to break ASI:

> As new syntactic features are added to ECMAScript, additional grammar productions could be added that cause lines relying on automatic semicolon insertion preceding them to change grammar productions when parsed.

So really, the rules are “there are currently 2 exceptions and an infinite number allowed to be added at any time”. To me, that’s worth letting prettier auto-insert semicolons when I hit save.

theThree

    function f1(a:number, b:number, c:number, d:number) {
        [a, b] = [b, a]
        [c, d] = [d, c]
        console.log(a, b, c, d)
    }
For the above codes Typescript gives error message: Type 'number[]' is not assignable to type 'number'.

skrebbel

I'd like to understand why `document.all` is slower than `getElementById`. Couldn't any even somewhat decent optimizing compiler trivially compile the first to the latter? Like, I don't mean in weird cases like `const all = document.all; return all[v]`, or iterating over it, just the general one where someone directly does `document.all.foo` or `document.all[v]`, ie the 99.99% case. When faced with the choice to compile those accesses to getElementById calls, or patch the ecmascript standard to put IE-compat workarounds in there, it seems kinda nuts to me that they would choose the latter, so I bet there'a good reason that I'm missing.

davidmurdoch

There was a time where there weren't optimizing compilers in JS engines, at least not anywhere near the level of sophistication they are at today.

In V8, not too long ago, any function over 400 characters, including comments, would bail out of optimization. We had lint rules to disallow these "long" functions.

masswerk

Regarding that choice: Given that this is really a different library (the DOM and its individual browser implementation), it's probably quite sane to just define a certain object to evaluate as falsy, as compared to any attempts to check for a certain implementation in this external library for any call.

(Even more so, since any access using `document.all` retrieves an object from a live collection, while the other access method is a function call, which is a totally different thing.)

mrguyorama

This was like 2004. Chrome, Safari, and Firefox all had getElementById in their first versions in like 2003, Opera had it in version 7, Internet Explorer was the odd one out.

This was IE6 days, the real bad old days. Remember that we were still mostly constrained to XMLHTTPRequests for calls to APIs

Anything actually important to be done in a web browser didn't use javascript, it used an ActiveX Component/extension, a java applet, or Flash or Shockwave (by Macromedia at the time!)