JavaScript is not random, but it can look random when you only see the output. Many surprising behaviors come from a few deep rules: coercion, scope, references, and the event loop.

Here are five behaviors worth understanding instead of memorizing.

Quick answer

Most JavaScript surprises come from one of these questions:

Surprise Rule underneath
Weird + results Values are converted before adding or joining
typeof null A historical language bug that stayed for compatibility
Loop callbacks show the wrong number var is function-scoped
this changes this depends on the call site
Promises run before timers Microtasks run before the next task

When you know the rule, the output becomes less mysterious.

1. [] + [] returns an empty string

Try this:

[] + []

The result is:

""

The + operator either adds numbers or concatenates strings. Arrays are converted to primitives first. An empty array becomes an empty string, so the expression becomes:

"" + ""

That is why the result is an empty string.

Now this:

[] + {}

often becomes:

"[object Object]"

The array converts to "", the object converts to "[object Object]", and string concatenation wins.

The lesson: avoid relying on implicit coercion in application code. Use explicit conversion when the value matters.

2. typeof null is object

This one is famous:

typeof null

returns:

"object"

This is a legacy behavior in JavaScript. It does not mean null is a normal object.

Check for null directly:

if (value === null) {
  // handle null
}

For object checks, remember to exclude null:

function isObject(value) {
  return typeof value === "object" && value !== null;
}

3. var in loops closes over one binding

This code surprises people:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

It prints:

3
3
3

var is function-scoped, so every callback closes over the same i. By the time the callbacks run, the loop is done and i is 3.

Use let:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Now each iteration gets its own binding, so it prints:

0
1
2

4. this depends on how a function is called

this is not where a function is written. It is usually determined by how the function is called.

const user = {
  name: "Aarav",
  sayName() {
    console.log(this.name);
  }
};

user.sayName();

This prints "Aarav" because the function is called as a method on user.

But:

const sayName = user.sayName;
sayName();

Now this is no longer user. The function was detached from the object.

When passing methods as callbacks, bind them or use an arrow wrapper:

button.addEventListener("click", () => user.sayName());

5. Promise callbacks run before setTimeout

This code:

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("sync");

prints:

sync
promise
timeout

Synchronous code runs first. Promise callbacks go into the microtask queue. setTimeout callbacks go into the task queue. After the current stack finishes, JavaScript drains microtasks before running the next task.

This matters when you debug UI updates, background jobs, and async ordering bugs.

The pattern underneath the weirdness

Most JavaScript surprises become less surprising when you ask:

  • Did a value get converted?
  • Is this a primitive or reference?
  • What scope does this variable live in?
  • How was the function called?
  • Is this callback a microtask or a task?

JavaScript has sharp edges, but the edges have rules. Learn the rules and the language starts feeling much less spooky.