Variables and Data Types
Variables Are Just Labels on Boxes
Every program needs to remember things. A user's name. A score. Whether the dark mode toggle is on. Variables are how JavaScript remembers — they're labels you stick on values so you can use them later.
But JavaScript gives you three different ways to create variables: var, let, and const. And they behave very differently under the hood. Understanding these differences will save you from an entire category of bugs that haunt developers for years.
Think of memory as a giant warehouse full of shelves. A variable is a sticky note you put on a shelf slot. const is a sticky note glued permanently to one slot — you can't peel it off and stick it somewhere else (but you can rearrange what's inside that slot if it's an object). let is a sticky note with reusable adhesive — you can move it to a different slot anytime. var is an old-school sticky note that ignores room walls (block scope) and floats up to the nearest room entrance (function scope), and the warehouse staff pre-registers it before you even walk in (hoisting with undefined).
Declaring Variables: var, let, and const
const — Your Default Choice
const means "this label stays on this value." You cannot reassign it.
const name = "Ada";
name = "Grace"; // TypeError: Assignment to constant variable
const age = 30;
age = 31; // TypeError
But here's the thing most people miss: const doesn't mean the value is frozen. It means the binding is frozen. If the value is an object, you can still mutate its contents:
const user = { name: "Ada", age: 30 };
user.age = 31; // totally fine — mutating the object
user = { name: "Grace" }; // TypeError — reassigning the binding
let — When You Need to Reassign
let is for variables that change. Loop counters, accumulators, values that get updated in conditional logic.
let score = 0;
score += 10; // fine
score = 100; // fine
let message; // declared without a value — starts as undefined
message = "hi"; // assigned later
var — The Legacy Keyword
var was the only option before ES6 (2015). You'll see it in older code, but you should almost never use it in new code. Here's why.
var count = 5;
count = 10; // works like let for reassignment
The problems with var aren't obvious in simple cases. They show up in scoping.
Scoping: Function vs Block
This is where var gets weird. let and const are block-scoped — they only exist inside the nearest {} curly braces. var is function-scoped — it ignores blocks entirely and belongs to the nearest function (or the global scope).
function example() {
if (true) {
var x = 1; // function-scoped — visible everywhere in example()
let y = 2; // block-scoped — only visible inside this if-block
const z = 3; // block-scoped — only visible inside this if-block
}
console.log(x); // 1 — var leaks out of the block
console.log(y); // ReferenceError — y doesn't exist here
console.log(z); // ReferenceError — z doesn't exist here
}
This is a classic source of bugs with loops:
// The classic var-in-a-loop bug
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 3, 3, 3 — not 0, 1, 2!
// Because var i is function-scoped. By the time the
// callbacks run, the loop has finished and i is 3.
// Fixed with let
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 0, 1, 2
// Each iteration gets its own block-scoped i.
Hoisting and the Temporal Dead Zone
When JavaScript enters a scope, it doesn't just run code top-to-bottom. First, it does a creation phase where it registers all variable bindings. What happens during that registration depends on the keyword:
- var: Registered and initialized to
undefined. You can access it before its declaration line, but it'sundefined. - let/const: Registered but NOT initialized. Accessing them before the declaration line throws
ReferenceError. The period between entering the scope and reaching the declaration is called the Temporal Dead Zone (TDZ).
console.log(a); // undefined — var is hoisted with undefined
console.log(b); // ReferenceError — let is in the TDZ
var a = 5;
let b = 10;
The TDZ exists to catch bugs. If let behaved like var, you'd silently get undefined instead of an error — and spend hours debugging why your calculation returns NaN instead of crashing loudly at the source.
Why typeof null === 'object' — a 30-year-old bug
In the very first implementation of JavaScript (1995, Brendan Eich, 10 days), values were stored internally as a small integer type tag plus the actual data. The type tag for objects was 0. null was represented as the NULL pointer — 0x00 — which also had a type tag of 0. When typeof checked the tag, it saw 0 and returned "object". This was never fixed because changing it would break existing websites that check typeof x === "object" and expect null to pass. The TC39 committee actually proposed a fix (typeof null === "null") in ES6, but it was withdrawn because it broke too many sites. So we're stuck with it forever.
The 7 Primitive Types
JavaScript has exactly 7 primitive types and one structural type (Object). Primitives are immutable values — you can't change the number 42 or the string "hello". You can only create new values.
string
Text. Always immutable. Single quotes, double quotes, or backticks (template literals) — no difference except backticks allow interpolation.
const greeting = "hello";
const name = 'world';
const message = `${greeting}, ${name}!`; // "hello, world!"
// Strings are immutable
greeting[0] = "H"; // silently fails (strict mode: TypeError)
console.log(greeting); // still "hello"
number
IEEE 754 double-precision floating point. Handles integers and decimals, but has limits.
const integer = 42;
const decimal = 3.14;
const negative = -7;
// Special number values
const inf = Infinity;
const negInf = -Infinity;
const notANumber = NaN;
// The floating-point trap
0.1 + 0.2 === 0.3; // false — it's 0.30000000000000004
bigint
For integers larger than Number.MAX_SAFE_INTEGER (2^53 - 1). Add n to the end.
const big = 9007199254740993n; // too large for Number
const alsobig = BigInt("9007199254740993");
// Can't mix with regular numbers
big + 1; // TypeError
big + 1n; // 9007199254740994n
boolean
true or false. That's it. But be careful with truthy and falsy values — JavaScript coerces other types to boolean in conditional contexts.
// Falsy values (everything else is truthy):
Boolean(false); // false
Boolean(0); // false
Boolean(-0); // false
Boolean(0n); // false
Boolean(""); // false
Boolean(null); // false
Boolean(undefined); // false
Boolean(NaN); // false
// Surprising truthy values:
Boolean("false"); // true — non-empty string
Boolean("0"); // true — non-empty string
Boolean([]); // true — empty array is truthy!
Boolean({}); // true — empty object is truthy!
null and undefined
Both represent "no value," but they mean different things:
undefined— a variable was declared but never assigned, or a function returned nothing, or a property doesn't existnull— an intentional "this is empty" value, set by the programmer
let x; // undefined — not assigned yet
const obj = {};
obj.missing; // undefined — property doesn't exist
function noReturn() {}
noReturn(); // undefined — no return statement
const empty = null; // null — intentionally empty
symbol
A unique, immutable identifier. Primarily used for object property keys that won't collide with anything else.
const id1 = Symbol("user");
const id2 = Symbol("user");
id1 === id2; // false — every Symbol is unique
const user = {};
user[id1] = "Ada";
user[id2] = "Grace";
// Both exist on the object without colliding
The typeof Operator and Its Quirks
typeof returns a string describing the type of a value. It's mostly predictable, with two famous exceptions:
typeof 42; // "number"
typeof "hello"; // "string"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof Symbol("x"); // "symbol"
typeof 42n; // "bigint"
typeof {}; // "object"
typeof []; // "object" — arrays are objects
typeof function(){} // "function" — special case for callable objects
// The two quirks:
typeof null; // "object" — the 30-year-old bug
typeof NaN; // "number" — NaN is technically a number value
To properly check for null, you need a direct comparison:
// Don't do this — catches null AND objects
if (typeof x === "object") { /* ... */ }
// Do this — explicit null check
if (x === null) { /* ... */ }
// Or check for both
if (x !== null && typeof x === "object") { /* ... */ }
To check for arrays, use Array.isArray():
Array.isArray([1, 2, 3]); // true
Array.isArray("hello"); // false
Array.isArray({ length: 3 }); // false — array-like but not an array
Type Coercion: Implicit vs Explicit
JavaScript automatically converts values between types in certain contexts. This is called implicit coercion (or type coercion). You can also do it yourself — that's explicit coercion (or type casting).
Explicit Coercion — You're in Control
// To string
String(42); // "42"
String(true); // "true"
String(null); // "null"
String(undefined); // "undefined"
// To number
Number("42"); // 42
Number(""); // 0 — empty string becomes 0
Number("hello"); // NaN
Number(true); // 1
Number(false); // 0
Number(null); // 0 — this surprises people
Number(undefined); // NaN — but this is NaN!
// To boolean
Boolean(0); // false
Boolean(""); // false
Boolean("hello"); // true
Boolean(42); // true
Implicit Coercion — JavaScript Decides for You
The + operator is the biggest source of coercion surprises. When one operand is a string, + concatenates. Otherwise, it adds.
"5" + 3; // "53" — number coerced to string
5 + "3"; // "53" — same thing
5 + 3; // 8 — both numbers, so addition
"5" - 3; // 2 — minus always converts to number
true + true; // 2 — booleans convert to 1
"" + 42; // "42" — sneaky way to convert to string
+[]; // 0 — unary + converts to number via ToPrimitive
Number(null) is 0 but Number(undefined) is NaN. This inconsistency exists because null represents "intentionally empty" (like zero on a form), while undefined represents "not even set" (like a form field that doesn't exist). The spec treats them differently during number conversion, and this can cause subtle bugs when you're doing arithmetic with values that might be either.
Primitives vs References
This is one of the most important concepts in JavaScript, and getting it wrong causes some of the nastiest bugs.
Primitives (string, number, boolean, null, undefined, symbol, bigint) are copied by value. When you assign a primitive to a new variable, you get a completely independent copy.
let a = 10;
let b = a; // b gets a COPY of 10
b = 20;
console.log(a); // 10 — unchanged, because b got its own copy
Objects (including arrays and functions) are assigned by reference. The variable holds a pointer to the object in memory, not the object itself. When you assign an object to a new variable, both variables point to the same object.
const original = { name: "Ada", scores: [90, 85] };
const copy = original; // copy points to the SAME object
copy.name = "Grace";
console.log(original.name); // "Grace" — original is mutated!
copy.scores.push(95);
console.log(original.scores); // [90, 85, 95] — same array
This is why you need to be deliberate about copying objects:
// Shallow copy — copies top-level properties
const shallow = { ...original };
shallow.name = "New Name";
console.log(original.name); // unchanged
// But nested objects are still shared references!
shallow.scores.push(100);
console.log(original.scores); // includes 100 — nested reference shared
// Deep copy — copies everything recursively
const deep = structuredClone(original);
deep.scores.push(200);
console.log(original.scores); // does NOT include 200
Equality and References
Two objects with identical contents are NOT equal with === because they're different objects in memory:
const a = { x: 1 };
const b = { x: 1 };
a === b; // false — different objects
const c = a;
a === c; // true — same reference
// Same applies to arrays
[1, 2, 3] === [1, 2, 3]; // false
To compare objects by their contents, you need to do it yourself (or use JSON.stringify for simple cases, or a deep-equal utility for complex ones).
When to Use const vs let
Here's the simple rule: use const by default. Use let only when you need to reassign. Never use var in new code.
// const for values that don't change binding
const API_URL = "/api/v1";
const MAX_RETRIES = 3;
const user = getUser(); // even if user is an object you'll mutate
// let for values that need reassignment
let count = 0;
for (let i = 0; i < items.length; i++) {
count += items[i].value;
}
let status = "pending";
if (isValid) {
status = "approved";
}
Why const by default? Because it communicates intent. When another developer reads your code and sees const, they instantly know this binding won't change. That's one less thing to track mentally. It reduces cognitive load, and in a 10,000-line codebase, that adds up fast.
| What developers do | What they should do |
|---|---|
| Thinking const makes values immutable const user = {}; user.name = 'Ada' is valid. const user = otherUser is not. | const prevents reassignment of the binding. Object contents can still be mutated. |
| Using var in new code for 'simplicity' var has function scoping, hoisting to undefined, and no TDZ protection — all of which hide bugs silently | Always use const by default, let when reassignment is needed |
| Checking typeof x === 'object' to detect objects typeof null is 'object' due to a historical bug. Your check will incorrectly match null. | First check x !== null, then check typeof — or use a more specific check |
| Assuming empty arrays and objects are falsy [] and {} are truthy because they are object references, and all object references are truthy. | Only 8 values are falsy: false, 0, -0, 0n, empty string, null, undefined, NaN. Everything else is truthy. |
| Using == instead of === for comparisons == triggers the Abstract Equality Algorithm with type coercion, producing results like [] == false being true. === compares value and type without coercion. | Use === (strict equality) unless you have a specific reason for loose equality |
- 1Use const by default. Use let only when you genuinely need to reassign. Never use var in new code.
- 2var is function-scoped and hoisted with undefined. let and const are block-scoped with TDZ protection.
- 3JavaScript has 7 primitives (string, number, bigint, boolean, null, undefined, symbol) and 1 structural type (Object).
- 4typeof null is 'object' (a 30-year-old spec bug). typeof NaN is 'number'. Use null checks and Array.isArray() for accurate type detection.
- 5Primitives are copied by value. Objects are assigned by reference — both variables point to the same object in memory.
- 6The + operator concatenates when either operand is a string. The - operator always converts to numbers.