Skip to main content

JavaScript Fundamentals

JavaScript is a dynamically typed, interpreted language. Understanding its type system and how values behave is critical to writing bug-free code. Think of JavaScript’s type system like a helpful but overeager assistant: it will try to make things work even when you hand it mismatched types, silently converting a string to a number or a number to a boolean behind your back. Sometimes that is convenient. Sometimes it produces bugs that are maddening to track down. This chapter gives you the foundation to always know what JavaScript is doing with your values and why.

1. How JavaScript Works

Unlike compiled languages like Java or C++, JavaScript is interpreted at runtime. Modern engines like V8 (Chrome, Node.js) use Just-In-Time (JIT) compilation for performance.

The Process

  1. Parsing: Your code is parsed into an Abstract Syntax Tree (AST).
  2. Interpreter: The AST is converted to bytecode and executed immediately.
  3. JIT Compiler: Hot code paths are compiled to optimized machine code.
Key Takeaway: JavaScript is fast enough for most use cases thanks to JIT compilation.

2. Variables & Declarations

JavaScript has three ways to declare variables. Use const by default, let when you need to reassign, and avoid var.

const (Block-scoped, No Reassignment)

let (Block-scoped, Reassignable)

var (Function-scoped, Hoisted) — Avoid!

Why avoid var? It is function-scoped, not block-scoped, and its declaration is hoisted to the top of the function. This means a variable can appear to exist before the line where you wrote it, and it can leak out of if and for blocks. These two behaviors are the source of a huge class of bugs in legacy JavaScript. Modern code uses const and let, which are block-scoped and behave the way you would expect coming from any other language.

var vs let vs const — Complete Comparison

Decision guide — which declaration to use:
  • Start with const for every variable. This is your default. It signals intent: “this binding will not change.”
  • Switch to let only when you need to reassign: loop counters, accumulators, values that change over time.
  • Never use var in new code. If you see it in a codebase, it is legacy or a bug. The only exception is if you are targeting an environment that does not support ES6 (extremely rare today).

3. Data Types

JavaScript has 8 data types: 7 primitives and 1 object type.

Primitive Types

Reference Type: Object

Everything that’s not a primitive is an Object. This includes arrays, functions, dates, and regular objects.

typeof — Results and Gotchas

The typeof operator returns a string indicating the type. It has a few famous surprises.

4. Type Coercion

JavaScript tries to be helpful by automatically converting types. This can lead to unexpected results. Think of it like an overly accommodating waiter: you order “five plus three” and instead of asking whether you meant the number five or the string “5”, the waiter just guesses. Sometimes the guess is right. Sometimes you get a concatenated string when you wanted arithmetic. The key rule to remember: the + operator prefers strings (if either side is a string, it concatenates), while -, *, and / always convert to numbers.

Number Edge Cases — Floating Point and Safe Integers

Implicit Coercion (Automatic)

Explicit Coercion (Intentional)

Truthy and Falsy Values

In boolean context, values are coerced to true or false. Memorize the falsy list — everything else is truthy. Falsy values (evaluate to false):
  • false, 0, -0, 0n, '', null, undefined, NaN
Everything else is truthy (including [], {}, '0', new Boolean(false)).
Edge cases that confuse everyone:
Practical tip: The truthy/falsy distinction matters most in conditional checks. A common bug is checking if (list.length) when the list has zero items — 0 is falsy, so this works. But if you write if (list) on an empty array, it is truthy because the array exists, even though it is empty. Always check .length for arrays.

5. Operators

Comparison: == vs ===

Always use === (strict equality). It checks value AND type.
== vs === — Complete Comparison When to use ==: Almost never. The only defensible use case is value == null, which checks for both null and undefined in a single comparison. Some style guides (including jQuery’s internal guide) allow this shorthand. Everything else should use ===.

Edge Cases That Bite: NaN and -0

Logical Operators

When to use || vs ??: Use || when you want to fall back on any falsy value (including 0, '', false). Use ?? when you only want to fall back if the value is null or undefined. In practice, ?? is almost always what you actually want for default values, because 0 and '' are often valid values you want to keep.

Optional Chaining (ES2020)

Safely access nested properties without checking each level.

6. Control Flow

Conditionals

Switch

Loops

for...in on arrays is a classic gotcha. It iterates over enumerable property keys (as strings), not values. Worse, it walks the prototype chain, so if anyone has added properties to Array.prototype, those will show up too. Use for...of for arrays, and reserve for...in for plain objects.

Loop Comparison — When to Use Which


|| vs ?? vs &&= vs ??= — Default Value Operators


Summary

  • Variables: Use const by default, let when needed. Avoid var.
  • Types: 7 primitives (number, string, boolean, null, undefined, symbol, bigint) + Object.
  • Coercion: JavaScript auto-converts types. Use === to avoid surprises.
  • Operators: Use ?? for null/undefined, ?. for safe property access.
  • typeof: Watch out for typeof null === 'object' and typeof [] === 'object'.
  • Numbers: 0.1 + 0.2 !== 0.3 — use integer arithmetic for money.
Next, we’ll dive deep into Functions & Scope, the heart of JavaScript.

Interview Deep-Dive

Strong Answer:
  • V8 (Chrome, Node.js) does not simply interpret JavaScript line by line. The process has multiple stages. First, the source code is parsed into an Abstract Syntax Tree (AST). The AST is then fed to Ignition, V8’s interpreter, which generates bytecode and begins executing it immediately. This gives fast startup — you do not wait for full compilation before seeing output.
  • As the code runs, V8’s profiler (the “feedback vector”) monitors which functions are called frequently (“hot” code paths) and what types of arguments they receive. When a function becomes hot enough, TurboFan (V8’s optimizing compiler) kicks in and compiles that specific function to highly optimized machine code, using the type information it has observed.
  • The critical gotcha is “deoptimization.” If TurboFan compiled a function assuming the first argument is always a number, and then you call it with a string, V8 must discard the optimized code and fall back to the interpreter. This is called a “bailout” or “deopt.” In a high-throughput service processing millions of events, deoptimizations can cause visible latency spikes. This is why writing “monomorphic” code (where functions always receive the same types) matters for performance-critical paths.
  • In practice, you rarely need to think about this directly. But when profiling a Node.js service and seeing unexplained latency, V8’s --trace-deopt flag can reveal deoptimizations. I have seen a production case where a utility function was being deoptimized on every call because it received both null and undefined as inputs — the types were polymorphic, preventing TurboFan from optimizing.
Follow-up: What is the Temporal Dead Zone, and why does it exist from a language design perspective?The TDZ is the period between a let/const variable being hoisted (the engine knows it exists) and the line where it is actually initialized. Accessing it in that window throws a ReferenceError. It exists because var’s behavior of silently returning undefined before initialization was a prolific bug source. The TDZ makes these bugs loud and immediate. From a specification standpoint, let and const bindings are created when the enclosing lexical environment is instantiated (hoisted), but they are not initialized until the declaration is evaluated. This is a deliberate design choice to catch “use before declaration” errors that var would silently swallow.
Strong Answer:
  • The complete falsy list in JavaScript is: false, 0, -0, 0n (BigInt zero), "" (empty string), null, undefined, NaN, and the historical oddity document.all. Everything else is truthy, including empty arrays [], empty objects {}, the string "0", the string "false", and new Boolean(false).
  • An empty array is truthy because truthiness in JavaScript is about the nature of the value, not its “emptiness.” Arrays and objects are reference types — they point to a location in memory. That reference exists and is not null, so it is truthy. An empty string, by contrast, is a primitive with no characters — it is conceptually “nothing,” like zero.
  • This distinction creates one of the most common bugs in JavaScript: if (myArray) is always true, even when the array is empty. You must check if (myArray.length) or if (myArray.length > 0). Similarly, if (myObject) does not tell you if the object has properties — you need if (Object.keys(myObject).length > 0).
  • In production, this bites people most often with API responses. An endpoint returns { items: [] } and the code checks if (response.items) — always true, so the UI renders an empty container instead of a “no results” message. The fix is always checking the actual content, not the container.
Follow-up: What would happen if you use == to compare null and 0? Walk me through the coercion steps.null == 0 evaluates to false. This surprises people because null == undefined is true and null in numeric context is 0. But the == algorithm has a special rule: null is only loosely equal to undefined and to itself. It does not trigger numeric coercion when compared to numbers, strings, or booleans. The spec explicitly defines null == 0 as false without going through the ToNumber path. This is one of the reasons == is treacherous — the rules are not consistently “convert to a common type and compare.” There are special cases baked into the algorithm.
Strong Answer:
  • The || operator returns the first truthy value. The ?? operator returns the first value that is not null or undefined. The difference matters when 0, "", or false are valid, intentional values.
  • The classic bug: const port = config.port || 3000. If config.port is 0 (a valid port, though unusual), || treats 0 as falsy and returns 3000. The user explicitly set port to 0 and the code silently overwrites it. With const port = config.port ?? 3000, the value 0 is preserved because it is not null or undefined.
  • I have seen this in production with a feature flag system. A flag called maxRetries was set to 0 for a specific client to disable retries entirely. The code used const retries = flagValue || 3, which silently replaced 0 with 3, causing that client’s failed requests to retry three times and hammer a downstream service. The fix was a one-character change: flagValue ?? 3.
  • The mental model: use || when you want to fall back on any “empty-ish” value (falsy). Use ?? when you only want to fall back when the value genuinely was not provided (null/undefined). In modern codebases, ?? is almost always what you actually want for default values.
Follow-up: Can you combine optional chaining with nullish coalescing? Give me a practical example and explain the evaluation order.Yes, they are designed to work together. A common pattern is const city = user?.address?.city ?? "Unknown". The evaluation: user?.address — if user is null/undefined, short-circuit to undefined. If not, access .address. Then ?.city — if address is null/undefined, short-circuit to undefined. If not, access .city. Now ?? kicks in: if the result is null or undefined, use "Unknown". Otherwise, keep the value. The key detail: optional chaining produces undefined on short-circuit (never null), and ?? catches both. This is the modern replacement for the verbose const city = user && user.address && user.address.city ? user.address.city : "Unknown" pattern.
Strong Answer:
  • NaN stands for “Not a Number” but its type is number (typeof NaN === 'number'). It represents the result of a nonsensical numeric operation: 0/0, parseInt("hello"), Math.sqrt(-1), undefined + 1.
  • NaN !== NaN is true because the IEEE 754 floating-point specification (which JavaScript follows) defines NaN as not equal to anything, including itself. The rationale: NaN represents an indeterminate value. 0/0 and parseInt("hello") both produce NaN, but they are not “the same value” in any meaningful sense. Making NaN unequal to itself prevents false equivalences between fundamentally different failed computations.
  • To check for NaN: use Number.isNaN(value). Do NOT use the global isNaN() function, which coerces its argument to a number first. isNaN("hello") returns true because Number("hello") is NaN. Number.isNaN("hello") returns false because "hello" is not NaN — it is a string. Number.isNaN only returns true for the actual NaN value.
  • A lesser-known check: value !== value is true only for NaN (since NaN is the only value not equal to itself). This was the idiomatic check before Number.isNaN existed, and you still see it in older codebases and polyfills.
  • Production gotcha: NaN propagates through calculations silently. NaN + 5 is NaN. NaN * 100 is NaN. If an early step in a financial calculation produces NaN (say, parsing a user input that is not a number), the final result is NaN, displayed as “NaN” in the UI or stored as null in the database. Always validate inputs at the boundary.
Follow-up: What about -0? When does it show up in practice and why does JavaScript have it?JavaScript uses IEEE 754 double-precision floats, which distinguish between +0 and -0. They are === equal (-0 === 0 is true), but Object.is(-0, 0) returns false. -0 appears from operations like Math.round(-0.1), -1 * 0, or parseFloat("-0"). In practice, -0 matters in mathematical contexts where the sign carries directional information (e.g., a velocity of -0 means “stopped but was moving left”). The sneaky bug: JSON.stringify(-0) produces "0", losing the sign. If you round-trip through JSON, the sign is lost. String(-0) also returns "0". The only reliable ways to detect -0 are Object.is(value, -0) or checking 1/value === -Infinity.