What and Why in JavaScript — Advanced Reference

A topic-organised reference for the deeper JavaScript questions that surface in code review and interviews — duck typing, host environments, property descriptors revisited, Symbol and WeakMap, iterators and generators, the event loop, and the rules behind value vs reference semantics.

How to read this post

This is the wrap-up for the JavaScript series. The previous eight posts were tutorials — they build mental models from first principles. This post is a reference: short answers to the deeper “what is this and why does it work that way” questions that the earlier tutorials hint at but rarely linger on.

Use the headings as an index. Each section is self-contained.

Type system

What is “duck typing”?

If it walks like a duck and quacks like a duck, treat it as a duck.

JavaScript has no interface keyword (TypeScript does, but those vanish at compile time). At runtime, code decides whether an object is suitable by checking the shape — does it have the methods I am about to call? — rather than its declared type.

1function play(media) {
2  if (typeof media.play !== "function") throw new TypeError("not playable");
3  return media.play();
4}

This is duck typing: the function works on any value that exposes a play method, regardless of how it was constructed.

Why does JavaScript need a “host environment”?

The ECMAScript language defines syntax, semantics, and the built-in library (Array, Promise, Math, …). It does not define how to read a file, open a network connection, display a button, or paint a pixel. Those capabilities are provided by a host:

  • The browser host adds window, document, fetch, localStorage, setTimeout, IndexedDB, …
  • Node.js adds fs, process, Buffer, EventEmitter, http, …
  • Deno and Bun add their own (mostly browser-compatible) host APIs.
  • Embedded engines (V8 in databases, JavaScriptCore in IoT) supply whatever the embedder exposes.

The same source code can run in any of them — as long as it touches only the standard library or host features known to be present.

Variables and properties

What is the difference between a variable and a property?

A variable is a binding in a lexical scope (introduced by let, const, var, function, class, import, or a function parameter).

A property is a key on an object — accessed with .name or ["name"].

The two collide at the top level: in non-module scripts a top-level var becomes a property of the global object (window / globalThis). Top-level let and const do not — they live in a separate “script scope”.

What is a “leaking global”?

An assignment to an undeclared identifier in non-strict mode creates a property on the global object:

1function bad() {
2  total = 10;        // no var/let/const → creates window.total
3}

In strict mode (automatic inside ES modules and class bodies), the same assignment throws a ReferenceError — which is the modern way to prevent the leak.

Property descriptors — the four-attribute story

Every property has a descriptor with four slots: value, writable, enumerable, configurable (or get, set, enumerable, configurable for accessors).

1const obj = {};
2Object.defineProperty(obj, "PI", {
3  value: 3.14159,
4  writable: false,
5  enumerable: true,
6  configurable: false,
7});
8obj.PI = 4;          // silently ignored (throws in strict mode)
9delete obj.PI;       // false in strict mode

The full attribute matrix is covered in Modern object features.

How do you iterate over non-enumerable properties?

1Object.getOwnPropertyNames(obj)   // all string-keyed own properties, including non-enumerable
2Object.getOwnPropertySymbols(obj) // all symbol-keyed own properties
3Reflect.ownKeys(obj)              // strings AND symbols

Object.keys and for...in only see enumerable string keys, by design.

Memory model

Value semantics vs reference semantics

Primitives — number, string, boolean, bigint, symbol, null, undefined — are values. Assignment copies the value:

1let a = 1; let b = a; b = 2;
2a;   // still 1

Objects — including arrays, functions, and class instances — are references. Assignment copies the reference; both variables point at the same object:

1const a = { x: 1 }; const b = a; b.x = 99;
2a.x;   // 99

This rule explains every “I changed something I didn’t mean to” bug in JavaScript. See the Objects basics post for cloning strategies.

What is a WeakMap / WeakSet for?

A Map keeps strong references to its keys, preventing them from being garbage-collected. WeakMap keys are weakly held — when no other reference exists, both the key and its associated value become eligible for collection.

Practical uses:

  • Attaching private data to objects you do not own (DOM nodes, library instances).
  • Caches keyed by object identity that should not extend lifetimes.
1const meta = new WeakMap();
2meta.set(domNode, { renderedAt: Date.now() });
3// when domNode is removed from the DOM and no other reference exists,
4// the entry is cleaned up automatically.

WeakMap keys must be objects — primitives cannot be weakly held.

Symbols and uniqueness

When should I use Symbol?

Three cases that come up often:

  1. Private-like keys on objects you share with other code:
    1const tag = Symbol("tag");
    2obj[tag] = "internal";   // invisible to Object.keys / for...in
    
  2. Enumerated constants that should never collide with strings:
    1const states = { Loading: Symbol("loading"), Ready: Symbol("ready") };
    
  3. Protocol participation — the well-known symbols (Symbol.iterator, Symbol.toPrimitive, Symbol.hasInstance, …) let your objects integrate with for...of, coercion, instanceof, and more. See the Modern object features post.

Symbol.for(key) consults a global registry — useful when the same symbol must be shared across realms (iframes, workers). Symbol(key) always returns a fresh symbol.

Iteration and generators

Iterable vs iterator vs generator — which is which?

TermDefinition
IterableAn object with [Symbol.iterator]() that returns an iterator.
IteratorAn object with .next() returning { value, done }.
GeneratorA function declared with function*. When called, it returns an iterator that is also iterable.

Every generator is both an iterable and an iterator (it has its own [Symbol.iterator] that returns itself). That is why for...of and spread “just work” on generator results.

When to reach for a generator

  • Lazy sequences (infinite ranges, parser tokens).
  • Tree / graph walks where you want the caller to control iteration speed.
  • Coroutine-style patterns before async/await existed (still useful for stateful protocols).

See Modern function features for working examples.

The event loop

Why is JavaScript single-threaded but non-blocking?

A JavaScript runtime runs the application code on one thread — the main thread — driven by an event loop. Long-running work yields control to the loop, which then dispatches queued tasks one at a time.

There are two queues that matter:

  • Macrotask queuesetTimeout, setInterval, I/O callbacks in Node, MessageChannel, UI events in browsers.
  • Microtask queue — promise reactions, queueMicrotask, and MutationObserver callbacks.

After every macrotask, the loop drains all pending microtasks before picking the next macrotask. That ordering explains output like:

1setTimeout(() => console.log("timeout"), 0);
2Promise.resolve().then(() => console.log("promise"));
3console.log("sync");
4
5// sync
6// promise
7// timeout

The synchronous code runs first, the promise reaction is a microtask scheduled before the timeout’s macrotask, so it logs second.

Why does a for loop block the UI?

Browsers paint between macrotasks. A long synchronous loop holds the main thread, so no paint happens until the loop ends — the page appears frozen. The fixes:

  • Break work into chunks and yield with setTimeout(fn, 0), requestIdleCallback, or await new Promise(r => setTimeout(r, 0)).
  • Move the work to a Web Worker (browser) or worker_threads (Node).
  • Use requestAnimationFrame for visual updates.

Equality and comparison

How many equality operations does JavaScript have?

Four:

OperationNotes
==Loose — coerces operand types. Avoid except x == null.
===Strict — same type and same value. The default.
Object.is(a, b)Like === except Object.is(NaN, NaN) === true and Object.is(0, -0) === false.
SameValueZeroThe internal algorithm used by Set, Map, and Array.prototype.includes. Same as === except NaN is equal to itself.

Why is [] == false true?

Loose equality runs a six-step coercion. [] becomes "" (the array’s default string conversion), "" becomes 0, false becomes 0, and 0 == 0 is true. Almost no real code wants this — use ===.

Error handling

throw, try, catch, finally — the working subset

1try {
2  doWork();
3} catch (err) {
4  if (!(err instanceof DomainError)) throw err;   // re-throw what you don't know
5  recover(err);
6} finally {
7  cleanUp();
8}

Two modern essentials:

  • catch without a bindingcatch { ... } if you do not need the error value.
  • Error.cause — attach an underlying error: throw new Error("upload failed", { cause: networkErr }). Loggers and DevTools show the full chain.

Why prefer custom error classes?

1class ValidationError extends Error {
2  constructor(message, field) {
3    super(message);
4    this.name  = "ValidationError";
5    this.field = field;
6  }
7}

Custom classes give you instanceof filtering in catch blocks, structured fields for logging, and consistent names in stack traces.

Modules and packaging

(Detailed in JavaScript Modules. Two questions worth repeating here.)

Why does Node treat .js as CommonJS by default?

Historical: CommonJS predates ESM by a decade, and Node’s huge ecosystem was built on require(). The "type": "module" field in package.json is the opt-in to make .js mean ESM for new code.

Why are ES module imports static?

Static import/export allow:

  • Tree-shaking — bundlers know exactly which exports are reachable.
  • Cycle detection at load time, with predictable resolution.
  • Asynchronous fetch in browsers — the import graph can be assembled and fetched in parallel before any code runs.

Dynamic import() exists for the cases where static is too rigid.

Performance and tooling tips

  • Measure, don’t guess. Use console.time, the Performance panel, or node --prof.
  • Avoid Object.assign({}, big, ...) in hot paths. Spread is faster in modern engines.
  • Prefer for or for...of over forEach for tight loops. Function-call overhead matters.
  • Map and Set outperform Object for frequent insert/delete or membership tests.
  • structuredClone is fast and correct for plain data. Skip JSON round-tripping.
  • Profile bundle size with source-map-explorer or rollup-plugin-visualizer. The biggest performance win is usually shipping less code.

Where to go next

  • The TC39 process at tc39.es — every new feature, in stages 0–4.
  • The MDN Web Docs — the canonical reference for every API mentioned here.
  • The V8 blog — deep dives on optimisation, memory, and the engine’s roadmap.

This concludes the JavaScript series. If you spotted gaps you would like covered, let me know — there is always room for another post.