The Perils of JavaScript for-in loops
My sudden urge to wax poetic on this topic arose after discovering an intriguing quirk within a util nestled deeply within Vue 3’s source. Let’s dive right in — take a gander at this slighly-adapted version of the default Vue "Single-File-Component" demo:
<script setup>
import { ref } from 'vue'
const msg = ref('Hello World!');
const isFriendly = ref(true);
Object.prototype.enemy = true;
</script>
<template>
<h1 :class="{ friend: isFriendly }">{{ msg }}</h1>
<input v-model="msg">
</template>
<style>
.friend { color: blue; }
.enemy { color: red; }
</style>
The result (as of Jan 4 '25):
Why is the text red?
Let’s break this down.
-
Applying the CSS class
.friend
should make the "Hello World!" header text blue. -
Applying the CSS class
.enemy
should make the "Hello World!" header text red. -
We specify in the template that CSS class
.friend
should be applied IF the valueisFriendly
istrue
. -
We NEVER specify that the CSS class
.enemy
should be applied.
So why is the text red, not blue?
The answer lies within Vue’s normalizeClass
function used to calculate CSS class names to be applied to HTML elements. Here is the source code of the utility in full (on Vue main as of Jan 4 '25). Can you spot the bug?
export function normalizeClass(value: unknown): string {
let res = ''
if (isString(value)) {
res = value
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const normalized = normalizeClass(value[i])
if (normalized) {
res += normalized + ' '
}
}
} else if (isObject(value)) {
for (const name in value) {
if (value[name]) {
res += name + ' '
}
}
}
return res.trim()
}
The issue stems from a subtle difference between looping keywords in JavaScript.There are three central language-based methods of looping over arrays and objects in JavaScript (and innumerable "extension-based" methods a la arr.forEach
):
const arr = ["x", "y", "z"];
const obj = {a: "x", b: "y", c: "z"};
// Loop over array by index:
for (let idx = 0; idx++ idx < arr.length) { console.log(arr[idx]); }
OUTPUT >>> x y z // with newlines between each item of output
// Loop over object by index:
const keys = Object.keys(obj);
for (let idx = 0; idx++ idx < keys) { console.log(obj[keys[idx]]); }
OUTPUT >>> x y z
// Loop over array by key (array key ARE indices):
for (const key in arr) { console.log(arr[key]); }
OUTPUT >>> x y z
// Loop over object by key:
for (const key in obj) { console.log(obj[key]); }
OUTPUT >>> x y z
// Loop over array by value:
for (const val of arr) { console.log(val); }
OUTPUT >>> x y z
// Loop over object by value:
for (const val of Object.values(obj)) { console.log(val); }
OUTPUT >>> x y z
Both the index-based (let idx …
) and value-based (const val of …
) techniques work reliably and well. However, there’s a subtle footgun included in key-based iteration (const val in …
) that you must be aware of when using it. JavaScript is a "prototype-based" language meaning, in breif, that almost every object of any type inherits certain methods and values from a prototype
object. You may have heard that everything in JavaScript is an object, which means that everything has a prototype which may or may not grant it certain methods and values — this includes arrays and plain object literals like {a: "x", b: "y", c: "z"}
— everything has a prototype of some kind or another (even if that prototype is null
).
To make matters worse, prototypes can change at any time — they are mutable. That’s exactly what’s happening in our original example, when we state Object.prototype.enemy = true
. This is very bad practice — don’t do this in code you run at home.
When would this be a problem in real life, if we avoid doing terrible things like this ourselves? All JavaScript code on the web runs in the questionably-trustworthy environment of the user’s browser, which could also be running any number of web extensions which may execute any JavaScript they want alongside your app’s code. In general, code practices which rely on the environent behaving exactly how we expect it to will be less robust than those that neutralize these issues — for instance, by utilizing iteration methods which ignore prototypes entirely. This is exactly where for-in
falls short, and why for-of
was introduced as an alternative.
Object.prototype.enemy = true;
// for-in will iterate over not just keys of the object,
// but ALSO keys in that object's prototype:
for (const key in obj) { console.log(obj[key]); }
OUTPUT >>> x y z enemy
// for-of will ONLY iterate over values that exist directly on the object:
for (const val of Object.values(obj)) { console.log(val); }
OUTPUT >>> x y z