JavaScript runs almost everywhere and refuses almost nothing, and those two facts explain most of the pain. It converts one type into another rather than stop; it decides what this means when you call a function, not when you write it; and it gives you one thread to do all the waiting on. None of it is magic: it is a small set of rules applied more literally than people expect. This is the shape of those rules — what the language actually does, where it will not warn you, and the habits that keep you out of the traps.
Each chapter opens with the short version. Tap one to read the detail.
Bindings, values, and what gets hoisted
~2 min
Use const by default and let when you must reassign; var is function-scoped and starts life holding undefined, which is why it reads as broken. And const fixes the binding, never the value — the properties of a constant object are still writable.
Every declaration reserves its name for the enclosing scope before any line runs; what differs is what the name holds until you reach it. A var name exists immediately, holding undefined, so a typo reads as a value instead of an error. A let or const name exists but is unusable until its own line — the temporal dead zone — and touching it early throws a ReferenceError. A function declaration is fully ready before its line, which is why you can call it from above.
The misconception to kill early is that const makes a value immutable. It fixes the binding only.
const config = { retries: 3 };
config.retries = 5; // fine — the object is not frozen
// config = {}; // TypeError: Assignment to constant variable.
The other half is what a variable holds. The seven primitives — string, number, bigint, boolean, symbol, undefined and null — are copied by value. Everything else is an object, and the variable holds a reference, so two names can point at one object and either can change it.
One relic to memorize rather than reason about: typeof null returns "object", a first-implementation bug kept for compatibility.
Rule: const by default, let when you reassign, var never — "constant" describes the name, not the value.
Coercion: the rules behind the jokes
~2 min
== converts before comparing and === does not, so use === and stop thinking about it. The conversions that actually cost you are + preferring string concatenation, and || treating 0 and the empty string as missing where ?? does not.
JavaScript rarely refuses a comparison; it converts until it can answer one. == runs those conversions, === refuses them, and Object.is differs from === in exactly two places: it calls NaN equal to itself and -0 different from 0. The one loose comparison worth carrying: null == undefined is true, while null == 0 is false.
The conversions that surprise people are arithmetic. + is overloaded — if either side ends up a string, it concatenates — while every other arithmetic operator converts to numbers.
'5' + 2 // '52' concatenation
'5' - 2 // 3 numeric
'2' < 12 // true numeric
'10' < '9' // true two strings, compared character by character
Then there is emptiness. The falsy values are false, 0, the empty string, null, undefined, NaN and a zero BigInt. So count || 10 throws away a legitimate zero, and name || 'anon' a legitimate empty string. ?? falls through only on null and undefined, which is usually what "nothing was supplied" means. Mixing ?? with || or && unparenthesized is a syntax error, deliberately.
&& and || also return an operand rather than a boolean: 'Cat' && 'Dog' is 'Dog'.
Rule: === always, ?? for defaults, and treat a bare + near user input as a bug until you have checked the types.
Functions, this, and closures
~2 min
this is decided by the call, not by where the function was written, so a method pulled off its object loses it. Arrow functions have no this of their own — which is exactly why they are right for callbacks and wrong for methods.
Parameters are the easy part: defaults, rest parameters and destructuring behave as written, and objects arrive as references, so a function can mutate the caller's object but cannot replace it.
this is what costs people days: it is bound at the call site. Call obj.method() and this is obj; take that same function and call it bare and this is undefined in strict mode and in modules.
Arrow functions have no binding of their own; they use whatever this surrounded them where they were written. That makes them right for a callback inside a method and wrong as a method, where they see the outer this instead of the object. bind also only takes once — re-binding an already bound function changes nothing.
Closures are the other half. An inner function keeps the binding, not a snapshot of the value: a var loop counter is shared by every closure the loop creates, while let gives each iteration its own.
Rule: find the call site before reasoning about this, and reach for an arrow when you want to inherit it.
Errors, and the block that outranks them
~2 min
Any value can be thrown, so a catch must never assume it received an Error. And finally outranks everything above it: a return there replaces the value you were returning and silently discards the exception you were throwing.
Throwing is not restricted to Error objects — a string, a number or a plain object is legal, which is why defensive code checks what it caught before reading .message. Prefer a built-in type (TypeError, RangeError, SyntaxError) or a subclass that sets its own name, and when you rethrow, pass the original along:
finally is the part people get wrong. It runs whether or not anything was thrown, and it runs before control actually leaves — including after a return has been evaluated. A return inside finally replaces that value, and if an exception was in flight it discards the exception entirely, so nobody upstream ever hears about it.
function f() {
try { throw new Error('boom'); } finally { return 'ok'; }
}
f(); // 'ok' — the error is gone
So finally is for cleanup: closing, releasing, restoring. If you want the cleanup without the value, catch can drop its binding entirely — catch { … } is valid syntax.
Rule: use finally to release things, never to return or throw from.
Looping, and the protocol underneath it
~2 min
for...in gives you keys, including inherited ones, and for...of gives you values — which is why for...in over an array is a bug waiting for somebody to add a property. forEach cannot be stopped: return ends one call and nothing more.
Two loops look interchangeable and are not. for...in walks enumerable property names, including inherited ones, and hands them over as strings. for...of asks the value for an iterator and hands you values.
const scores = [10, 20, 30];
scores.total = 60;
for (const k in scores) { /* '0', '1', '2', 'total' */ }
for (const v of scores) { /* 10, 20, 30 */ }
forEach earns its own warning: a return inside it merely ends that one call, and there is no break at all. When you need to stop early, use for...of, or some, find or findIndex, which stop on their own.
for...of works on anything implementing the iterator protocol — a method under Symbol.iterator returning an object whose next() yields { value, done }. Generators write that for you and compute each value on demand, so an infinite sequence is fine as long as you stop consuming it. Each generator is consumed once.
function* ids() { let n = 1; while (true) yield n++; }
const next = ids();
next.next().value; // 1
next.next().value; // 2
Rule: for...of for values, Object.keys for keys, and a plain loop whenever you might need to break.
Objects: keys, copies, and interception
~2 min
Keys are only strings or symbols, so anything else is stringified and every object used as a key collapses to the same one. Spread, Object.assign and Object.freeze all stop one level down — nested objects stay shared and stay writable.
A key is only ever a string or a symbol. Anything else is converted, which is why using objects as keys silently collapses them all onto "[object Object]". Symbols exist for the opposite reason: each one is unique even against another with the same description, so a symbol key cannot collide and never shows up in Object.keys.
The recurring bug is depth. Every built-in copy is shallow.
const user = { name: 'Ada', address: { city: 'London' } };
const copy = { ...user };
copy.address.city = 'Paris';
user.address.city; // 'Paris' — the same nested object
Object.assign copies the same way and additionally reads getters and fires setters on the target. Object.freeze is shallow too: a frozen object's nested array can still be pushed into. When you need a real deep copy, structuredClone does it.
Two more distinctions repay learning: in searches the prototype chain while Object.hasOwn does not, and delete removes an own property only.
A Proxy wraps an object and intercepts operations — get, set, has — with Reflect supplying the default to fall back on. It cannot lie about a frozen target: a trap that contradicts one throws.
Rule: assume every copy is one level deep, and use Object.hasOwn when "does it have this?" must exclude the prototype.
Prototypes, and classes as the syntax over them
~2 min
Every object has a prototype, and a failed lookup walks that chain — but assignment never does, so writing always creates an own property that shadows the inherited one. class is the readable syntax for this: methods are shared on the prototype, fields are per instance.
Reading a property searches the object, then its prototype, then that object's prototype, until the chain runs out and you get undefined. Writing never walks: an assignment always creates an own property, shadowing the inherited one and leaving the prototype untouched.
class is syntax for that arrangement plus some real additions: the body is always strict mode, and a class declaration sits in the temporal dead zone like let, so it cannot be used above its own line. Methods are defined once on the prototype and shared by every instance, while a field is created per instance.
That is not a detail. A prototype method extracted into a callback loses this like any other function, whereas an arrow stored in a field captures this at construction and keeps working — at the cost of one function object per instance.
#name fields are genuinely private: reading one from outside is a syntax error, not a convention. And a subclass constructor must call super() before touching this.
Rule: methods on the prototype by default, an arrow field only for a callback you intend to detach.
Numbers: one type, finite precision
~2 min
There is one number type, a 64-bit float, so 0.1 + 0.2 is not 0.3 and integers stop being exact above about nine quadrillion. NaN is a number that never equals itself, and toFixed hands back a string.
Every ordinary number is a double-precision 64-bit float carrying roughly 15 to 17 significant decimal digits. Two consequences follow, and both bite in production.
Most base-ten fractions have no exact binary form, so arithmetic drifts:
Compare with a tolerance, or hold money as whole cents. Integers stay exact only up to Number.MAX_SAFE_INTEGER, which is 9,007,199,254,740,991; past it two different literals can be the same value. Identifiers from a database therefore belong in a string or a BigInt, which refuses to mix with ordinary numbers rather than lose precision quietly.
NaN is the failure value and is not equal to itself, so x !== x is true only when x is NaN. Use Number.isNaN, never the global isNaN, which converts first and so calls the string 'hello' a NaN.
Conversions differ as well: Number('12px') is NaN while parseInt('12px') is 12. And toFixed returns a string — (0.5).toFixed(2) is '0.50' — so an accidental concatenation is one missing conversion away.
Rule: never compare floats with ===, and never let an identifier become a number.
Text: code units, patterns, and locale
~2 min
A string is a sequence of 16-bit code units, not characters, so an emoji has a length of 2 and index-based slicing can cut one in half. A regular expression with the global flag remembers where it stopped, which makes a shared one silently skip matches.
Strings are immutable sequences of 16-bit code units. Characters outside the basic range take two units, so '😄'.length is 2, split('') tears it in half and slice(0, 1) returns half a character. Iteration fixes it: spreading or for...of walks whole code points, so [...'😄'].length is 1.
Equality is the second trap. Accented text has more than one valid encoding, so two strings that render identically can compare unequal and even differ in length. Call normalize() on both before comparing.
Regular expressions carry state. With the global flag the object remembers where it stopped:
const re = /a/g;
re.test('a'); // true
re.test('a'); // false — resumed past the first match
Build the pattern where you use it rather than sharing one. That flag also changes what match returns — plain strings for every match, dropping capture groups and positions — which is why matchAll exists.
For anything a person reads, comparison and formatting are locale work, not string work: by code unit 'ä' > 'z' is true, while an Intl.Collator for German sorts it beside a. Intl.NumberFormat and Intl.DateTimeFormat do the same job for numbers and dates.
Rule: iterate strings instead of indexing them, and give every global regular expression a fresh object.
Arrays, maps, sets, and buffers
~2 min
sort() with no comparator converts everything to strings, so a list of numbers comes back in dictionary order. Reach for a Map once keys stop being simple strings — and remember it matches object keys by identity, not by shape.
Arrays are objects with a writable length and integer-like keys, which explains most of their oddities. The default sort compares elements as strings, and it mutates in place:
[5, 40, 300].sort(); // [300, 40, 5]
[5, 40, 300].sort((a, b) => a - b); // [5, 40, 300]
toSorted, toReversed, toSpliced and with are the copying versions. A second classic: callbacks receive (value, index, array), so ['1','2','3'].map(parseInt) gives [1, NaN, NaN] — the index arrives as the radix. Pass a one-argument arrow. includes finds NaN where indexOf cannot, because indexOf uses strict equality.
A plain object is a poor dictionary. Map takes any key type, keeps insertion order, reports size directly and starts genuinely empty. It matches keys by identity, so a freshly built object of the same shape is a different key. Set stores unique values under the same rule, and WeakMap holds keys weakly so cached data does not pin objects in memory.
Binary data is separate again: an ArrayBuffer is bytes you cannot touch directly — you read it through a typed array or a DataView, and two views over one buffer share memory.
Rule: always pass a comparator to sort, and switch to a Map the moment your keys stop being simple strings.
One thread: promises, await, and the event loop
~2 min
There is one thread, so nothing else runs while your code does. Promise callbacks are microtasks and run before any timer however short, and await inside a loop turns independent work serial — the most common reason async code is slow.
Everything runs to completion on one thread. A callback never interrupts you mid-function, and one long synchronous computation freezes timers, input and rendering alike. Asynchrony here is scheduling, not parallelism.
The scheduler has two queues and the order is worth memorizing. When the stack empties, every pending microtask runs — promise reactions and queueMicrotask — and only then does the next task, such as a timer or an event, get its turn:
The function you pass to new Promise runs synchronously; only the reactions are deferred. then returns a new promise, so a chain is built by returning — forget the return inside a then and the next step receives undefined while the work you started floats away unwatched.
await pauses one function, not the program. Awaiting inside a loop runs independent requests one after another; when they do not depend on each other, start them all and await together with Promise.all, which rejects the moment any one does where allSettled waits for every outcome.
Rule: await when the next step needs the last result, Promise.all when it does not.
Modules: static, live, and evaluated once
~2 min
Imports are resolved before any code runs, so you cannot import conditionally — that is what import() is for. What you import is a live read-only view of the exporter's binding, and each module body runs exactly once no matter how many files import it.
A module is not a script. Its top level is its own scope, not the global one; it is strict mode automatically; and its body evaluates once on first import however many files pull it in. Import declarations are hoisted, so a module's dependencies finish evaluating before its own first line.
What you import is a live, read-only view — not a copy of the value.
// counter.js
export let hits = 0;
export function bump() { hits += 1; }
// main.js
import { hits, bump } from './counter.js';
bump(); bump();
hits; // 2 — the binding, not a snapshot
// hits = 0; // TypeError: Assignment to constant variable
Because resolution is static, an import cannot sit inside an if. Use import('./heavy.js'), which returns a promise for the module and is what code-splitting is built on.
Cycles are the sharp edge. Two modules may import each other, and whether that works depends on which is the entry point and whether a value is read while the other is still evaluating. Read too early and you get a ReferenceError from the temporal dead zone — the same cycle can be fine one way round and broken the other.
Rule: keep imports static and at the top, use import() for conditional loading, and treat a cycle as a design smell rather than a puzzle.
The boundary: JSON and dates
~2 min
A round trip through JSON is lossy by design: undefined and functions vanish from objects, become null inside arrays, and a date comes back as a string. Date parsing has one rule everybody trips on — a date-only string is read as universal time, a date-time string without an offset as local.
JSON is a serialization format, not a mirror of your values. Stringify drops undefined, functions and symbols from objects and replaces them with null inside arrays; NaN and Infinity become null; a Map or Set serializes as {}; a cycle or a BigInt throws a TypeError. Anything carrying a toJSON method decides its own output, which is how a date becomes a string.
const out = JSON.stringify({ at: new Date(), tags: new Set([1]) });
JSON.parse(out); // { at: '2026-08-02T…', tags: {} }
So parsing hands you plain data. Revive types deliberately, and never assume a round trip preserves a class.
Date has its own famous edges. It counts milliseconds from the start of 1970, months are zero-indexed — new Date(2026, 0, 2) is January — out-of-range parts roll over silently, and instances are mutable, so a setter changes every reference. The parsing rule is the one to memorize: '2026-03-01' is read as universal time while '2026-03-01T00:00:00' is read as local, which puts them hours apart.
The standardized replacement is Temporal, immutable and time-zone aware, but runtime support is still arriving — check before depending on it.
Rule: treat parsed JSON as untyped data, and store instants as universal time, formatting only at the edge.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.