JavaScript Values and Types: Primitives vs References, typeof Quirks, == vs ===, NaN and BigInt

Key takeaways

A JavaScript variable holds either a primitive or a reference to an object, and most confusing behavior (shared mutations, typeof null, == coercion, NaN !== NaN, lost precision above 2^53) follows from that and from IEEE 754 numbers. This article explains each with real Node.js output.

JavaScript is dynamically typed: variables do not have types, values do. That sentence is easy to repeat and hard to internalize, and most “JavaScript is weird” moments come from not fully applying it. This article focuses on how values behave once they are in a variable: the difference between primitives and objects, what typeof really reports, how == coerces, why NaN breaks equality, and where numbers stop being exact. All outputs were produced with Node.js 24.

For the declaration keywords themselves (block scope, hoisting, the temporal dead zone, closures in loops), see the dedicated comparison: var vs let vs const. Here is the short version before moving on.

Declarations in one paragraph

Use const by default and let when you need to reassign; avoid var in new code because it is function-scoped and can be redeclared. const forbids rebinding, not mutation:

const PI = 3.14;
PI = 3;          // TypeError: Assignment to constant variable.
const x;         // SyntaxError: Missing initializer in const declaration

const person = { name: "Alice" };
person.name = "Bob";  // allowed: the object is mutable, the binding is not

That last line leads directly to the most important idea in this article.

Primitives vs references

JavaScript has seven primitive types: number, bigint, string, boolean, undefined, symbol and null. Everything else (plain objects, arrays, functions, dates, maps) is an object.

Primitives are immutable values. Assigning one to another variable gives that variable its own value:

let s1 = "hi";
let s2 = s1;
s2 += "!";
console.log(s1, s2);  // hi hi!

Even string methods cannot change a string in place; they return new strings:

const str = "hello";
str[0] = "H";   // silently ignored in sloppy mode; in strict mode:
                // TypeError: Cannot assign to read only property '0' of string 'hello'
console.log(str.toUpperCase(), str);  // HELLO hello

Objects are different. A variable holds a reference to the object, and assignment copies the reference:

let a = { n: 1 };
let b = a;
b.n = 2;
console.log(a.n);  // 2  -- same object

Passing to functions

JavaScript always passes arguments by value, but for objects “the value” is the reference. So a function can mutate an object you pass in, but it cannot replace the caller’s variable:

function bump(obj, num) { obj.count++; num++; }
const o = { count: 0 };
let k = 0;
bump(o, k);
console.log(o.count, k);  // 1 0

function reassign(obj) { obj = { count: 99 }; }
reassign(o);
console.log(o.count);     // 1  -- the caller's o is untouched

Comparing objects

=== on objects compares references, not contents:

console.log({ x: 1 } === { x: 1 }, [1] == [1]);  // false false

There is no built-in deep equality operator. Tests use helpers such as Node’s assert.deepStrictEqual or a library function; in application code, compare the fields you actually care about.

Copying: shallow vs deep

Spread ({ ...obj }, [...arr]) and Object.assign make shallow copies: the top level is new, nested objects are still shared.

const orig = { name: "Ada", tags: ["a"], when: new Date(0) };

const shallow = { ...orig };
shallow.tags.push("b");
console.log(orig.tags);  // [ 'a', 'b' ]  -- the nested array was shared

const deep = structuredClone(orig);
deep.tags.push("c");
console.log(orig.tags, deep.tags, deep.when instanceof Date);
// [ 'a', 'b' ] [ 'a', 'b', 'c' ] true

structuredClone (Node 17+, all current browsers) handles nested objects, arrays, Date, Map, Set and cycles; it throws on functions and DOM nodes. The older JSON.parse(JSON.stringify(x)) trick is lossy: dates come back as strings, undefined properties and functions disappear, and BigInt throws.

const viaJson = JSON.parse(JSON.stringify(orig));
console.log(typeof viaJson.when);  // string

The bug I have run into most often here is not exotic at all: a “default options” object gets spread into a new config, a nested array on the new config gets pushed to, and the defaults are now polluted for every later caller. Nothing throws; the second request just behaves differently from the first. Since then I treat shared defaults as something to create fresh inside a function (or deep-freeze), rather than trusting every caller to copy deeply.

Object.freeze is shallow too

const cfg = Object.freeze({ port: 80, db: { host: "a" } });
cfg.port = 81;       // ignored in sloppy mode
cfg.db.host = "b";   // works: nested object is not frozen
console.log(cfg.port, cfg.db.host);  // 80 b

In strict mode (ES modules and class bodies are always strict) the first assignment throws instead of failing silently:

TypeError: Cannot assign to read only property 'port' of object '#<Object>'

What typeof actually tells you

typeof 42            // "number"
typeof 42n           // "bigint"
typeof "s"           // "string"
typeof true          // "boolean"
typeof undefined     // "undefined"
typeof Symbol()      // "symbol"
typeof null          // "object"    <- historical bug
typeof {}            // "object"
typeof []            // "object"    <- arrays are objects
typeof function(){}  // "function"
typeof class {}      // "function"  <- classes are functions
typeof NaN           // "number"    <- "not a number" is a number

typeof null === "object" dates back to the first implementation, where null shared the object type tag. It can never be fixed without breaking the web, so check value === null explicitly, and Array.isArray(value) for arrays. When you need a precise tag for built-ins, Object.prototype.toString.call(value) returns strings like "[object Null]" or "[object Date]".

typeof also has a special property: it does not throw on undeclared identifiers, which is why feature detection like typeof window !== "undefined" works in Node.

console.log(typeof notDeclaredAnywhere);  // undefined

The exception is a let/const variable in its temporal dead zone:

typeof tdz;
let tdz = 1;
// ReferenceError: Cannot access 'tdz' before initialization

undefined vs null

Both mean “no value”, but by convention undefined is what the language gives you (uninitialized variables, missing properties, functions without return), and null is what programmers assign on purpose to say “intentionally empty”. APIs are easier to use when they pick one; JSON has only null, and JSON.stringify drops properties whose value is undefined.

Symbols

Every Symbol() is unique, even with the same description, and symbol-keyed properties are skipped by Object.keys and JSON.stringify, which makes them useful for metadata that should not leak into serialized output:

const id = Symbol("id");
const u = { [id]: 1, name: "a" };
console.log(Object.keys(u), JSON.stringify(u), u[id]);  // [ 'name' ] {"name":"a"} 1
console.log(Symbol("x") === Symbol("x"), Symbol.for("x") === Symbol.for("x"));  // false true

== vs ===

=== (strict equality) compares type and value with no conversion. == (loose equality) first converts operands according to the Abstract Equality algorithm, and the results are not even transitive:

0 == ""         // true
0 == "0"        // true
"" == "0"       // false   <- so == is not transitive
[] == false     // true
[] == ![]       // true
true == "1"     // true
true == "true"  // false   <- true becomes 1, "true" becomes NaN
[1, 2] == "1,2" // true    <- array converted via toString()

The rules roughly are: null and undefined equal each other and nothing else; booleans are converted to numbers; when comparing a string to a number the string becomes a number; objects are converted to primitives via valueOf/toString. The null rule produces a famous oddity with relational operators, which use a different algorithm:

null == 0    // false
null >= 0    // true   (>= converts null to 0; == does not)

The one widely accepted use of == is checking for both null and undefined at once:

if (input == null) {  // true for null or undefined, nothing else
  // ...
}

Everywhere else, use ===. ESLint’s eqeqeq rule (with the "null": "ignore" option if you want that idiom) enforces this automatically.

NaN, -0 and Object.is

NaN is the only JavaScript value that is not equal to itself:

NaN === NaN              // false
[NaN].indexOf(NaN)       // -1     (uses ===)
[NaN].includes(NaN)      // true   (uses SameValueZero)
Object.is(NaN, NaN)      // true

Two functions test for it, and they are not the same:

isNaN("hello")         // true   <- converts to Number first
Number.isNaN("hello")  // false  <- only true for the actual NaN value
isNaN(undefined)       // true

Prefer Number.isNaN. The global isNaN really answers “would this become NaN if converted to a number?”, which is rarely the question you mean.

JavaScript also has a negative zero. 0 === -0 is true, but Object.is(0, -0) is false and 1 / -0 is -Infinity. It mostly matters in math-heavy or charting code.

I have lost more time to NaN than to any other value, because it propagates silently: one bad parse early in a calculation turns every downstream number into NaN, and the first visible symptom is a UI showing “NaN” far from the actual cause. The habit that helps is validating at the boundary (right after Number() or parseFloat on user input or API data) with Number.isFinite, which rejects NaN and Infinity in one check.

Converting between types

To number

Number() is strict about the whole string; parseInt/parseFloat read a prefix and stop:

Number("42px")        // NaN
parseInt("42px")      // 42
Number("")            // 0     <- empty string is 0, not NaN
Number(" ")           // 0
parseInt("")          // NaN
Number(null)          // 0
Number(undefined)     // NaN
Number("0x1F")        // 31
parseFloat("3.14.15") // 3.14

Number("") being 0 is a common source of “the empty form field became zero” bugs; check for empty strings before converting. Two classic parseInt traps:

parseInt(0.0000005)             // 5   <- the number is stringified as "5e-7" first
["1", "2", "3"].map(parseInt)   // [ 1, NaN, NaN ]  <- map passes the index as the radix
["1", "2", "3"].map(Number)     // [ 1, 2, 3 ]

Implicit conversion with operators

+ concatenates if either operand is a string; the other arithmetic operators convert to numbers:

"10" + 20       // "1020"
"10" - 2        // 8
"3" * "4"       // 12
"5" + 1 - 1     // 50   <- ("5" + 1) is "51", then 51 - 1
[] + []         // ""
[] + {}         // "[object Object]"
1 + null        // 1
1 + undefined   // NaN

Values read from form inputs, query strings and localStorage are always strings, so input.value + 1 concatenates. Convert explicitly with Number() at the point where you read the value.

To boolean

The falsy values are false, 0, -0, 0n, "", null, undefined and NaN. Everything else is truthy, including "false", "0", [] and {}:

Boolean("false")  // true
Boolean([])       // true
Boolean(0n)       // false

Because 0 and "" are falsy, || is the wrong tool for defaults when those are valid values. Use ?? (nullish coalescing), which only falls back on null and undefined:

const count = 0;
count || 10    // 10   <- probably a bug
count ?? 10    // 0

Numbers: precision and safe integers

Every number is an IEEE 754 double-precision float. That gives you roughly 15 to 17 significant decimal digits and exact integers only up to 2^53 - 1:

0.1 + 0.2                // 0.30000000000000004
0.1 + 0.2 === 0.3        // false
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON  // true

Number.MAX_SAFE_INTEGER  // 9007199254740991
2 ** 53 + 1              // 9007199254740992   <- cannot be represented
9007199254740992 === 9007199254740993  // true

Rounding for display is also less obvious than it looks:

(0.1 + 0.2).toFixed(2)          // "0.30"
(1.005).toFixed(2)              // "1.00"  <- 1.005 is really 1.00499999...
Math.round(1.005 * 100) / 100   // 1

For money, store integer minor units (cents) or use a decimal library rather than doing arithmetic on floats.

The safe-integer limit matters most at the JSON boundary. JSON.parse produces numbers, so a 64-bit ID from another system is silently rounded:

JSON.parse('{"id": 12345678901234567890}').id  // 12345678901234567000

No error, just a different ID. APIs that emit 64-bit identifiers usually send them as strings for exactly this reason; if yours does not, you need a JSON parser that supports big integers.

BigInt

BigInt (ES2020) represents arbitrary-precision integers. Write them with an n suffix or construct them with BigInt():

const big = 9007199254740993n;
big + 1n     // 9007199254740994n
2n ** 64n    // 18446744073709551616n
7n / 2n      // 3n    <- integer division, truncates toward zero
-7n / 2n     // -3n

The restrictions are strict on purpose, to avoid silent precision loss:

big + 1
// TypeError: Cannot mix BigInt and other types, use explicit conversions

Math.max(1n, 2n)
// TypeError: Cannot convert a BigInt value to a number

JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt

BigInt(1.5)
// RangeError: The number 1.5 cannot be converted to a BigInt because it is not an integer

Comparisons across the two types do work: 1n == 1 is true, 1n === 1 is false (different types), and 2n > 1 is true. Converting back with Number(bigValue) rounds if the value is above the safe range. For JSON, a replacer turns BigInts into strings:

JSON.stringify({ id: 10n }, (k, v) => typeof v === "bigint" ? v.toString() : v);
// '{"id":"10"}'

BigInt arithmetic is slower than number arithmetic, so it is a tool for values that must be exact (IDs, cryptography, counters that can exceed 2^53), not a general replacement.

Wrapper objects: never use new on primitives

String, Number and Boolean called as functions convert values. Called with new, they create wrapper objects, which behave like objects:

typeof new String("a")                   // "object"
new Boolean(false) ? "truthy" : "falsy"  // "truthy"  <- it is an object

There is no reason to create these explicitly. Calling methods on primitives ("abc".toUpperCase(), (5).toString(2)) works because the engine temporarily wraps them for you.

Next in the series

Functions and closures come next in JavaScript functions, followed by arrays and objects, where the primitive-versus-reference distinction from this post decides how copying and spreading behave.