DEV Community

Mitha
Mitha

Posted on

10 JavaScript Behaviors That Look Weird Until You Understand the Rules

JavaScript has a reputation for surprising even experienced developers. Many of its quirks aren't arbitrary, though—they follow directly from the language's coercion rules, execution model, parsing rules, and historical design decisions.

Coming to JavaScript after working with more structured languages like C can make some of these behaviors even more confusing.

Here are 10 common JavaScript behaviors that look strange at first, along with what's actually happening under the hood.

1. typeof NaN === "number" Is true

A common assumption is that NaN—"Not a Number"—should have a type other than number.

But:

console.log(typeof NaN);
// "number"
Enter fullscreen mode Exit fullscreen mode

This makes more sense once we look at what NaN actually is.

JavaScript's Number type is based on the IEEE 754 double-precision floating-point format. NaN is a special numeric value in that format that represents the result of an operation that doesn't produce a meaningful numeric value.

For example:

console.log(0 / 0);
// NaN

console.log(Number("hello"));
// NaN
Enter fullscreen mode Exit fullscreen mode

Even though these operations don't produce a usable number, their result is still represented using JavaScript's Number type.

So NaN isn't "not a number" in the sense of having a non-numeric JavaScript type. It's a special value within the Number type.

One more important detail: NaN is not equal to itself.

console.log(NaN === NaN);
// false
Enter fullscreen mode Exit fullscreen mode

If you need to check for NaN, use Number.isNaN():

console.log(Number.isNaN(NaN));
// true
Enter fullscreen mode Exit fullscreen mode

Takeaway: NaN is a special IEEE 754 floating-point value, which is why JavaScript considers its type to be number.


2. Why [] + [] and {} + [] Behave Differently

These two expressions look almost identical:

[] + []
Enter fullscreen mode Exit fullscreen mode

and:

{} + []
Enter fullscreen mode Exit fullscreen mode

But JavaScript can produce surprising results:

console.log([] + []);
// ""

console.log({} + []);
// 0  (in certain contexts)
Enter fullscreen mode Exit fullscreen mode

The first result comes from JavaScript's type coercion rules.

When the + operator is used with objects, JavaScript first converts those objects to primitive values. Arrays are converted to strings using their toString() method:

console.log([].toString());
// ""
Enter fullscreen mode Exit fullscreen mode

So:

[] + []
Enter fullscreen mode Exit fullscreen mode

effectively becomes:

"" + ""
Enter fullscreen mode Exit fullscreen mode

which produces:

""
Enter fullscreen mode Exit fullscreen mode

The second expression is more subtle because parsing context matters.

When {} appears at the beginning of a statement, JavaScript can interpret it as an empty block rather than an object literal:

{} + []
Enter fullscreen mode Exit fullscreen mode

The empty block doesn't produce a value. JavaScript then evaluates:

+[]
Enter fullscreen mode Exit fullscreen mode

The unary + converts its operand to a number. An empty array is converted to an empty string, which is then converted to 0:

+[];
// 0
Enter fullscreen mode Exit fullscreen mode

So the overall result can be:

0
Enter fullscreen mode Exit fullscreen mode

However, this isn't a universal property of the expression {} + []. Put the same expression in an expression context and the parsing can change.

For example:

console.log({} + []);
// "[object Object]"
Enter fullscreen mode Exit fullscreen mode

The important lesson isn't really that "{} + [] equals 0."

It's that JavaScript's parser has to determine what {} means before the type coercion rules even come into play.

Takeaway: When JavaScript behaves strangely, there can be more than one mechanism involved. Parsing happens first; coercion happens later.


3. Why 0.1 + 0.2 !== 0.3

This is one of the most famous floating-point surprises in programming.

You might expect:

0.1 + 0.2
Enter fullscreen mode Exit fullscreen mode

to produce exactly:

0.3
Enter fullscreen mode Exit fullscreen mode

But:

console.log(0.1 + 0.2);
// 0.30000000000000004

console.log(0.1 + 0.2 === 0.3);
// false
Enter fullscreen mode Exit fullscreen mode

The problem isn't specific to JavaScript. It comes from how floating-point numbers are represented.

JavaScript's Number type uses IEEE 754 binary floating-point. Computers represent numbers internally using binary, or base 2.

The problem is that some decimal fractions that look simple in base 10 cannot be represented exactly as finite binary fractions.

For example, 0.1 doesn't have an exact representation in IEEE 754 binary floating-point. The closest representable value is used instead.

The same is true for 0.2.

When those approximations are added together, the result is slightly different from the exact mathematical value 0.3.

That's why this:

0.1 + 0.2
Enter fullscreen mode Exit fullscreen mode

produces something close to, but not exactly:

0.3
Enter fullscreen mode Exit fullscreen mode

For calculations where exact decimal arithmetic matters—such as financial calculations—you need to account for this behavior rather than relying on direct floating-point equality.

Takeaway: Floating-point numbers are approximations. Don't assume that decimal values that look exact to us can always be represented exactly by a computer.


4. Array.prototype.sort() Doesn't Default to Numeric Sorting

Consider this array:

const numbers = [1, 20, 10, 5];

console.log(numbers.sort());
// [1, 10, 20, 5]
Enter fullscreen mode Exit fullscreen mode

If you expected:

[1, 5, 10, 20]
Enter fullscreen mode Exit fullscreen mode

JavaScript's behavior can seem strange.

The reason is that Array.prototype.sort() uses string comparison when you don't provide a comparison function.

Conceptually, the numbers are compared using their string representations:

1
20
10
5
Enter fullscreen mode Exit fullscreen mode

String comparison puts "10" before "20" and "5" after both of them, producing:

[1, 10, 20, 5]
Enter fullscreen mode Exit fullscreen mode

To sort numbers numerically, provide a comparison function:

const numbers = [1, 20, 10, 5];

numbers.sort((a, b) => a - b);

console.log(numbers);
// [1, 5, 10, 20]
Enter fullscreen mode Exit fullscreen mode

The comparison function tells sort() how the elements should be ordered.

For ascending numerical order:

(a, b) => a - b
Enter fullscreen mode Exit fullscreen mode

For descending order:

(a, b) => b - a
Enter fullscreen mode Exit fullscreen mode

Takeaway: sort() doesn't know that your values are intended to represent numbers. If you need numeric ordering, explicitly provide a comparison function.


5. Automatic Semicolon Insertion (ASI) Can Change Your Code

JavaScript doesn't require you to explicitly write a semicolon after every statement.

For example:

const name = "Alice"
const age = 30
Enter fullscreen mode Exit fullscreen mode

is valid JavaScript.

This is partly because of Automatic Semicolon Insertion (ASI), a set of language rules that allows certain line breaks to terminate statements.

Most of the time, this works exactly as you'd expect.

But there are cases where a line break can change the meaning of your code.

A. The return Pitfall

Consider:

function getUser() {
  return
  {
    name: "Alice"
  }
}

console.log(getUser());
// undefined
Enter fullscreen mode Exit fullscreen mode

It might look like the function is returning the object:

{
  name: "Alice"
}
Enter fullscreen mode Exit fullscreen mode

But JavaScript treats the line break after return as the end of the return statement.

Conceptually, this becomes:

function getUser() {
  return;
  {
    name: "Alice"
  }
}
Enter fullscreen mode Exit fullscreen mode

The function therefore returns undefined.

This is why you'll often see the opening { placed on the same line as return:

function getUser() {
  return {
    name: "Alice"
  };
}
Enter fullscreen mode Exit fullscreen mode

The same kind of issue can occur with other restricted statements such as throw, break, and continue.

B. The IIFE / Array Pitfall

ASI can also cause problems in the opposite direction: JavaScript may not insert a semicolon when the next line can syntactically continue the previous statement.

For example:

let x = 42

(function () {
  console.log("IIFE")
})()
Enter fullscreen mode Exit fullscreen mode

A developer might expect this to be two separate statements.

But JavaScript can interpret it as though the function expression is being applied to 42:

let x = 42(function () {
  console.log("IIFE")
})()
Enter fullscreen mode Exit fullscreen mode

which results in:

TypeError: 42 is not a function
Enter fullscreen mode Exit fullscreen mode

This is one reason you'll sometimes see defensive semicolons before immediately invoked function expressions:

let x = 42;

;(function () {
  console.log("IIFE");
})()
Enter fullscreen mode Exit fullscreen mode

Modern formatters such as Prettier, along with linting rules, can help prevent many of these problems.

Takeaway: ASI makes semicolons optional in many situations, but line breaks aren't always neutral. Be especially careful around return and lines beginning with (, [, or template literals.


6. let and const Are Hoisted: Meet the Temporal Dead Zone

A common explanation is that let and const aren't hoisted.

That's not quite accurate.

They are hoisted in the sense that their bindings are created when the JavaScript engine sets up the surrounding lexical environment. The important difference is that those bindings aren't initialized immediately.

Consider:

console.log(a);
// undefined

console.log(b);
// ReferenceError: Cannot access 'b' before initialization

var a = 1;
let b = 2;
Enter fullscreen mode Exit fullscreen mode

With var, the variable is initialized with undefined during environment setup.

With let and const, the binding exists but remains uninitialized until execution reaches the declaration.

The period between entering the scope and reaching the declaration is called the Temporal Dead Zone (TDZ).

You can think of it roughly like this:

// b exists, but is uninitialized here

console.log(b);
// ReferenceError

let b = 2;

// b is initialized here
Enter fullscreen mode Exit fullscreen mode

This distinction is important because saying "let isn't hoisted" can lead to an incomplete mental model.

A better model is:

  • var is hoisted and initialized with undefined.
  • let and const are hoisted but remain uninitialized.
  • Accessing an uninitialized let or const binding during the TDZ throws a ReferenceError.

Takeaway: The difference isn't whether let and const are hoisted. The important difference is when their bindings become initialized.


7. Why var and let Behave Differently in for Loops

This classic example demonstrates the interaction between variable scope, closures, and asynchronous callbacks.

Using var:

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

// 3
// 3
// 3
Enter fullscreen mode Exit fullscreen mode

But using let:

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

// 0
// 1
// 2
Enter fullscreen mode Exit fullscreen mode

Why?

var is function-scoped rather than block-scoped. The loop uses a single i binding.

By the time the callbacks execute, the loop has already finished:

i = 3
Enter fullscreen mode Exit fullscreen mode

All three callbacks close over the same binding, so they all read 3.

let behaves differently in a for loop. JavaScript creates a separate per-iteration binding for the loop variable.

Conceptually, the callbacks end up associated with different bindings:

iteration 1 → i = 0
iteration 2 → i = 1
iteration 3 → i = 2
Enter fullscreen mode Exit fullscreen mode

So when the callbacks eventually execute, each one reads the value associated with its iteration.

This isn't really about setTimeout() itself. The same closure behavior can appear whenever a callback outlives the iteration in which it was created.

Takeaway: var gives you one shared function-scoped binding, while let provides per-iteration bindings in for loops.


8. Loose Equality (==) Has Coercion Rules

JavaScript's loose equality operator, ==, is often criticized for producing surprising results:

console.log("" == 0);
// true

console.log(false == 0);
// true

console.log("" == false);
// true
Enter fullscreen mode Exit fullscreen mode

These results aren't random. They're consequences of JavaScript's defined equality algorithm.

For example:

"" == 0

When a string is compared with a number, JavaScript converts the string to a number.

Number("");
// 0
Enter fullscreen mode Exit fullscreen mode

So the comparison effectively becomes:

0 == 0
Enter fullscreen mode Exit fullscreen mode

which is true.

false == 0

When a boolean is compared with a number, the boolean is converted to a number:

Number(false);
// 0
Enter fullscreen mode Exit fullscreen mode

So:

false == 0
Enter fullscreen mode Exit fullscreen mode

becomes effectively:

0 == 0
Enter fullscreen mode Exit fullscreen mode

and evaluates to true.

"" == false

This one involves multiple coercions.

The boolean is first converted to a number:

false → 0
Enter fullscreen mode Exit fullscreen mode

Then the string is converted to a number:

"" → 0
Enter fullscreen mode Exit fullscreen mode

So the comparison eventually becomes:

0 == 0
Enter fullscreen mode Exit fullscreen mode

and evaluates to true.

This is why == can be difficult to reason about if you don't have its coercion rules memorized.

In most application code, === is easier to reason about because it doesn't perform this kind of implicit type conversion.

console.log("" === 0);
// false
Enter fullscreen mode Exit fullscreen mode

That doesn't mean == is inherently broken or unusable. Its behavior is well-defined, and there are situations where developers deliberately use it.

But if you don't specifically want JavaScript's coercion rules, === is usually the clearer choice.

Takeaway: == isn't unpredictable—it has complicated coercion rules. === avoids those conversions and is generally easier to reason about.


9. Object Keys Are Coerced to Strings

In a regular JavaScript object, property keys are either strings or symbols.

That can lead to a surprising result when you try to use objects as keys.

Consider:

const obj = {};

const a = { key: "a" };
const b = { key: "b" };

obj[a] = 123;
obj[b] = 456;

console.log(obj[a]);
// 456
Enter fullscreen mode Exit fullscreen mode

Why did assigning b overwrite the value associated with a?

When an object is used as a property key, JavaScript converts it to a property key. For ordinary objects, that conversion produces a string:

String(a);
// "[object Object]"

String(b);
// "[object Object]"
Enter fullscreen mode Exit fullscreen mode

So these two assignments are effectively doing the same thing:

obj["[object Object]"] = 123;
obj["[object Object]"] = 456;
Enter fullscreen mode Exit fullscreen mode

The second assignment overwrites the first.

If you actually want objects to be distinct keys, use a Map:

const map = new Map();

map.set(a, 123);
map.set(b, 456);

console.log(map.get(a));
// 123

console.log(map.get(b));
// 456
Enter fullscreen mode Exit fullscreen mode

Map is specifically designed to associate values with keys without converting object keys into strings.

Takeaway: Plain objects are primarily string/symbol-keyed dictionaries. If you need objects, arrays, or other values to remain distinct keys, Map is usually the better abstraction.


10. this Is Dynamic in Regular Functions but Lexical in Arrow Functions

One of the most common sources of confusion in JavaScript is this.

Consider:

const user = {
  name: "Alice",

  regularFunc: function () {
    console.log(this.name);
  },

  arrowFunc: () => {
    console.log(this.name);
  }
};

user.regularFunc();
// "Alice"

user.arrowFunc();
// Depends on the surrounding context
Enter fullscreen mode Exit fullscreen mode

The key difference is that regular functions and arrow functions determine this differently.

Regular functions: this depends on how they're called

When we call:

user.regularFunc();
Enter fullscreen mode Exit fullscreen mode

the function is called as a method of user.

As a result:

this === user
Enter fullscreen mode Exit fullscreen mode

inside the function.

So:

this.name
Enter fullscreen mode Exit fullscreen mode

is equivalent to:

user.name
Enter fullscreen mode Exit fullscreen mode

and produces:

Alice
Enter fullscreen mode Exit fullscreen mode

Arrow functions: this is lexical

Arrow functions don't have their own this binding.

Instead, they capture this from their surrounding lexical context.

This means that the arrow function inside our object doesn't automatically get user as its this.

The exact result depends on where the object is created.

For example, in an ES module, top-level this is undefined:

console.log(this);
// undefined
Enter fullscreen mode Exit fullscreen mode

An arrow function defined there captures that value.

In a browser's classic script, top-level this can instead refer to the global object.

This is why relying on the result of the example without specifying the execution environment can be misleading.

The important rule is:

Regular functions get this based on how they're called. Arrow functions capture this from their surrounding scope.

This is also why arrow functions are particularly useful for callbacks:

const user = {
  name: "Alice",

  greet() {
    setTimeout(() => {
      console.log(this.name);
    }, 100);
  }
};

user.greet();
// "Alice"
Enter fullscreen mode Exit fullscreen mode

The arrow function doesn't create a new this; it captures the this from greet().

Takeaway: Don't think of arrow functions as a shorter way to write regular functions. Their handling of this is fundamentally different.

Top comments (1)

Collapse
 
kanunilabs profile image
KanuniLabs •

these are the kind of javascript quirks that become much less confusing once you understand the rules behind them. the examples around coercion, closures and this are especially useful because they show why the language behaves that way instead of just showing the weird result.

nice collection, good read.