Practice JS curriculum Foundations
Chapters 1-4
Draft
Private preview - this deep page is unpublished.
Sidebar link will be added once content is approved. Not indexed by search engines.
Curriculum . JavaScript . Foundations

JavaScript foundations

Everything you need to know before you ever write a Playwright spec - the runtime, the engine, the variable rules, the operators, every twist that trips students up. Maps to chapters 1-4 of the LearningPlaywrightBatch curriculum: basics, var/let/const + hoisting, identifiers and literals, and the operator families. Every concept gets a fresh code sample, a diagram, and a drill prompt.

4
Chapters covered
Basics, hoisting + scope, identifiers + literals, operators.
~30
Programs walked
Every original code sample mirrors a file from the upstream batch.
~45
Diagrams
Mermaid pipelines, sequence flows, and hand-styled inline SVGs.
~30
Drill prompts
Predict-the-output puzzles you can run in node -e.

01What is JavaScript ch 1 - 01_basic.js

JavaScript is the language of the web - and now also the language Playwright tests are written in. It started in 1995 as a 10-day project by Brendan Eich at Netscape, originally called LiveScript and then Mocha. Today it is a multi-paradigm, dynamically typed, single-threaded language that runs in browsers (Chrome's V8, Firefox's SpiderMonkey, Safari's JavaScriptCore), on servers via Node.js, in serverless edge runtimes, and inside embedded contexts like database functions.

Four facts that define how it behaves

  • Dynamically typed - a binding (a name) does not have a type, only the value does. The same name can hold a number now and a string a line later.
  • Single-threaded - one call stack, one thread of execution. Concurrency is achieved with the event loop, not OS threads.
  • Prototype-based - objects inherit from other objects through a prototype chain. The class keyword is syntactic sugar on top of that.
  • First-class functions - a function is a value. You can store it in a variable, pass it as an argument, return it from another function.
flowchart TB
  JS[JavaScript Source] --> B[Browsers]
  JS --> S[Servers]
  JS --> E[Edge / Embedded]
  B --> V8[Chromium - V8]
  B --> SM[Firefox - SpiderMonkey]
  B --> JSC[Safari - JavaScriptCore]
  S --> NODE[Node.js - V8]
  S --> DENO[Deno - V8]
  S --> BUN[Bun - JavaScriptCore]
  E --> CF[Cloudflare Workers]
  E --> VC[Vercel Edge]
  E --> EMBED[Embedded scripting in DBs]
Where JavaScript runs - the same syntax, six different engines and many runtimes
timeline
  title JavaScript family tree
  1995 : LiveScript / Mocha at Netscape
       : Renamed JavaScript
  1997 : ECMAScript 1 standard
  2009 : ES5 - strict mode + JSON
  2015 : ES2015 (ES6) - let, const, class, arrow, modules
  2017 : ES2017 - async / await
  2020 : ES2020 - optional chaining, nullish coalescing, BigInt
  2026 : modern engines fully ship every yearly cut
JS family tree - the spec is yearly, runtimes ship the new features within 6-12 months
Browser tab V8 / SM / JSC Node.js (server) V8 + libuv Edge / Worker V8 isolate Same .js file - different host APIs window / document / fetch / fs / crypto
Same .js source, three host shapes - what differs is the host API, not the language

Tiny first program

Every chapter 1 batch lecture opens with a hello-world. Here is ours, written fresh - notice the four facts in action: no type for name, the function is passed as a value, the call stack is one frame deep.

hello.js
// hello.js - run with: node hello.js
const greet = (name) => `hello, ${name}!`;

const name = 'tta learner';
console.log(greet(name));
// -> hello, tta learner!
Drill 1.1 Open a node REPL with node, type typeof greet. Then assign the return value of greet to a variable and check typeof again. What do the two answers tell you about first-class functions?

02How the V8 engine works ch 1 - 02_JS_Step_By_Step.js

V8 is Google's JavaScript engine, the one inside Chrome and Node.js. It does not interpret your source line by line - it does something cleverer. The source is parsed once into an abstract syntax tree, the AST is fed to an interpreter called Ignition that emits bytecode, the bytecode runs, and a profiler called the hotness counter watches which functions get called a lot. When a function crosses the hotness threshold, V8 promotes it to TurboFan, an optimising JIT compiler that produces native machine code with type-assumption shortcuts. If those assumptions later fail at runtime, V8 quietly drops back to bytecode - this is called de-optimisation.

flowchart LR
  S[Your .js source] --> P[Parser]
  P --> A[AST - tree of nodes]
  A --> I[Ignition Interpreter]
  I --> B[Bytecode]
  B --> X[Execute one op at a time]
  X --> H{Hot enough?}
  H -- no --> X
  H -- yes --> T[TurboFan JIT]
  T --> M[Optimised machine code]
  M --> CPU[CPU executes natively]
  CPU --> OK{Types still match assumption?}
  OK -- yes --> CPU
  OK -- no --> D[De-optimise -> back to bytecode]
  D --> X
V8 pipeline - parse, interpret, watch, promote, optimise, fall back
flowchart TB
  subgraph STACK[Stack - tiny + fast - primitives + call frames]
    F1[number, boolean, undefined, null, symbol]
    F2[function call frames - LIFO]
  end
  subgraph HEAP[Heap - big + dynamic - objects + closures]
    H1[Object { ... }]
    H2[Array]
    H3[Function with closure]
    H4[String - unless small]
  end
  STACK -.references.-> HEAP
Memory model - stack holds primitives + frames, heap holds objects
your .js function add(a,b) Ignition bytecode ops TurboFan optimised asm CPU executes native instructions "hot loop" - JIT promotes after ~10k calls
From your source to native instructions - the optimiser only kicks in for hot code

Hot-loop demo - watching JIT promote a function

The upstream 04_hot_code.js file demos a tight loop that gets optimised after a few thousand calls. Here is our version, original code, illustrating the same idea. Run with node --trace-opt --trace-deopt hot.js and watch V8 print the promotion line.

hot.js
// hot.js - same function called ~50k times
function square(x) {
  return x * x;
}

let total = 0;
for (let i = 0; i < 50_000; i++) {
  total += square(i);
}

console.log('total =', total);
// node --trace-opt hot.js
// you will see something like: [marking ... for optimisation]
Why JIT matters for test code. A Playwright spec that runs a parser over thousands of DOM nodes inside a page.evaluate gets the same JIT treatment - your in-browser helper functions become almost native speed after a warm-up. Keep helper signatures monomorphic (always same shape of arguments) so V8 can keep the optimised version live.
flowchart TD
  CALL[function called many times] --> IC[Inline Cache observes types]
  IC --> MONO{always same shape?}
  MONO -- yes --> MONOMORPH[monomorphic - fast inline]
  MONO -- no --> POLY{2 or 3 shapes?}
  POLY -- yes --> POLYMORPH[polymorphic - small dispatch]
  POLY -- no --> MEGA[megamorphic - de-opt path]
Inline caches - monomorphic call sites stay fast, megamorphic ones fall back to bytecode
Drill 2.1
  1. Run the program with node --trace-opt --trace-deopt hot.js and find the iteration count where V8 logs the promotion line.
  2. Then call square('5') from the loop occasionally. Re-run with the same flags - what does V8 print and why?
  3. Bonus: rewrite square as an arrow assigned to a const - does the JIT behaviour change?

03Runtime + setup ch 1 - 03_verify_setup.js

Before any of this matters, you need a runtime. For Playwright tests we use Node.js - specifically an LTS version (long-term support), currently Node 20 or Node 22. The Node installer ships node (the runtime), npm (the package manager), and npx (a runner for one-off tools). On Windows we recommend the official installer; on macOS use brew install node; on Linux use nvm so multiple versions can coexist.

flowchart LR
  A[Download Node LTS] --> B[Run installer]
  B --> C[node -v]
  C --> D[npm -v]
  D --> E[npx -v]
  E --> F[node -e \"console.log('ok')\"]
  F --> G[Ready to run Playwright]
Node install flow - five quick checks before you write a test
Your test file - logIn.spec.ts Playwright (npm package + browsers) Node.js runtime (V8 + libuv) Your operating system - macOS / Windows / Linux
The runtime stack - every layer sits on the one below

The verify-setup snippet

A one-file sanity check, original code. Save as verify.js and run with node verify.js. Confirms the version, the global name, and that process is available.

verify.js
// verify.js - quick environment probe
console.log('node version :', process.version);
console.log('platform     :', process.platform);
console.log('global name  :', typeof window === 'undefined' ? 'globalThis (node)' : 'window (browser)');
console.log('cwd          :', process.cwd());
console.log('argv         :', process.argv.slice(2));
// run: node verify.js hello world
// argv -> ['hello', 'world']

Two flavours of global - quick

Browser code reaches for window. Node code reaches for global or the modern alias globalThis. The latter is the one you should learn first - it works in both environments and is the recommended modern path.

global.js
// global.js - same name in any runtime
globalThis.TTA = { batch: 'playwright-2026' };
console.log(globalThis.TTA.batch);
// works in node AND in a browser tab
flowchart TB
  ROOT[globalThis - same name everywhere]
  ROOT --> B[in browser: window, document, fetch]
  ROOT --> N[in node: process, Buffer, require]
  ROOT --> W[in worker: self, postMessage]
  ROOT --> D[in deno: Deno, fetch]
One global object, several aliases - prefer globalThis in shared code
Drill 3.1
  1. Run node -e "console.log(typeof window)" on your machine. Then run the same snippet inside a browser DevTools console. Compare the outputs.
  2. What does typeof globalThis return in each environment?

04Comments + identifiers ch 2 - 05_Core_Comments, 06_Identifier

Comments and identifiers are the smallest building blocks - the things you write before you ever reach a keyword. JavaScript has two comment forms: single-line // and block /* ... */. There is also a third pseudo-comment - the JSDoc tag, written as /** ... */ - which TypeScript and editors read for type hints. Identifiers are the names you give to variables, functions, classes, and properties.

Comment forms

comments.js
// single-line - run to end of line
const sku = 'tta-001'; // trailing comment also works

/*
   block comment - spans
   multiple lines, can wrap
   a paragraph of intent
*/
const price = 199;

/**
 * JSDoc - typed comment for IDEs
 * @param {string} name - learner name
 * @returns {string} greeting
 */
function hi(name) { return `hi ${name}`; }
flowchart LR
  C[a source character] --> T{// or /*}
  T -- // --> LINE[single-line comment - to end of line]
  T -- /* --> BLOCK[block comment - until first close */]
  T -- /** --> DOC[JSDoc - TS / editor reads it for hints]
  T -- none --> CODE[real code - parsed normally]
How the parser decides what a comment is - three openers

Identifier rules - what is a legal name

An identifier may start with a letter, an underscore, or a dollar sign. After the first character, digits are also allowed. Names are case-sensitive (price and Price are different). Reserved words cannot be used as identifiers - things like let, class, return, function.

IdentifierLegal?Why
priceyesletter start, all letters
_skuNameyesunderscore start is fine
$elyesdollar start is fine (used a lot in jQuery + Playwright helpers)
price1yesdigit allowed after the first character
1pricenocannot start with a digit
letnoreserved keyword
classnoreserved keyword
my pricenospaces are not allowed in identifiers
my-pricenohyphens are subtraction, not name characters
my_priceyesunderscores are fine
First character letter a-z A-Z underscore _ dollar $ NOT 0-9 After first char letter digit 0-9 _ and $ NOT space, NOT -
Identifier rules - the first character is stricter than the rest

Reserved words you cannot use

A short list of keywords that the parser owns and you cannot reuse as identifiers:

breakcasecatch classconstcontinue debuggerdefaultdelete doelseenum exportextendsfinally forfunctionif implementsimportin instanceofinterfacelet newnullpackage privateprotectedpublic returnstaticsuper switchthisthrow trytypeofvar voidwhilewith yieldtruefalse
Drill 4.1 Decide which of these four identifiers is illegal and explain why for each: let, 123abc, $_a, class. Then try them at a node REPL and compare the error messages.

05var vs let vs const ch 2 - 07_var_let_const, 08_Lab, 15_let_block, 18_const

Three ways to declare a binding. Picking the wrong one is the most common chapter-2 bug. The short answer: default to const, fall back to let when you need to reassign, never use var in new code. The long answer is the table below - and the diagrams next to it explain why this matters for tests.

Side-by-side comparison

Propertyvarletconst
Scopefunctionblockblock
Hoistingdeclared + initialised to undefineddeclared but TDZdeclared but TDZ
Re-declare in same scopeyesnono
Re-assignyesyesno
Must initialise on declarenonoyes
Becomes a global propertyyes (in scripts)nono
Recommended for new codenowhen reassigningdefault choice
flowchart LR
  subgraph FN[function scope - var]
    V1[var x inside function visible everywhere in fn]
    V2[var inside if/for STILL visible in fn]
  end
  subgraph BL[block scope - let / const]
    L1[let / const inside { } visible only in that block]
    L2[lifetime ends at closing brace]
  end
  V1 -.style.-> LEAK[often leaks unexpectedly]
  L1 -.style.-> TIGHT[predictable lifetimes]
Function scope vs block scope - the difference is everything
function outer() - one big bubble if (true) { - inner block, smaller bubble var leaks UP to outer | let / const trapped here block ends with closing brace var x = 1 (inside if) visible outside the if scope = function let y = 1 (inside if) NOT visible outside scope = block
Bubble diagram - var leaks out of the if, let stays trapped inside

One program per keyword

var-fn-scope.js
// var - leaks out of any block except function
function main() {
  if (true) {
    var teamSize = 12;
  }
  console.log(teamSize); // 12 - var is function-scoped, not block
}
main();
let-block-scope.js
// let - trapped inside the nearest { } block
function main() {
  if (true) {
    let teamSize = 12;
    console.log(teamSize); // 12 - visible here
  }
  // console.log(teamSize); // ReferenceError: not defined
}
main();
const-no-reassign.js
// const - binding cannot be reassigned, but contents can mutate
const limit = 100;
// limit = 200;  // TypeError: Assignment to constant variable

const queue = [1, 2];
queue.push(3);    // OK - mutating the array, not the binding
console.log(queue); // [1, 2, 3]

// queue = [];   // TypeError - rebinding the name is what's blocked

The classic loop closure trap

Old JavaScript courses still teach this puzzle: a for loop with var + setTimeout prints the wrong number. With let, the same loop prints what you would expect - because let creates a fresh binding per iteration.

loop-closure.js
// with var - prints 3, 3, 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 10);
}

// with let - prints 0, 1, 2
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 10);
}
sequenceDiagram
  participant Loop as for loop
  participant Q as Macrotask queue
  Loop->>Loop: i=0 - schedule cb that reads i later
  Loop->>Loop: i=1 - schedule another cb
  Loop->>Loop: i=2 - schedule another cb
  Loop->>Loop: loop ends, i is now 3
  Q->>Q: cb1 fires, reads outer i -> 3
  Q->>Q: cb2 fires, reads outer i -> 3
  Q->>Q: cb3 fires, reads outer i -> 3
var loop trap - all three callbacks share ONE i, which is 3 when they run
Drill 5.1
  1. Predict: let a = 1; let a = 2; in the same scope - error or fine?
  2. Predict: const obj = { n: 1 }; obj.n = 2; obj = { n: 3 }; - which line throws?
  3. Take a var-driven double-loop matrix builder you've written and convert it to let + const. Note any behaviour change.
  4. Run the var-loop puzzle above. Then change just var to let and predict the new output before running.

06Hoisting - deep dive ch 2 - 09 to 16 hoisting variants

Hoisting is the most-asked-about feature of chapter 2, and the one that confuses students for the longest time. The good news: there is one mental model that explains every variant. The bad news: that model needs you to think in two passes. Once it clicks, every hoisting puzzle becomes mechanical.

The two-pass model

When JavaScript starts executing a function (or a script), the engine does NOT just walk the lines top to bottom. It runs two passes over the code:

  1. Parse / declaration phase - the engine scans the body and creates bindings for every var, let, const, function, and class it finds, BEFORE executing the first line. This is where "hoisting" happens.
  2. Execution phase - the engine now runs the code line by line, evaluating expressions and assigning to those bindings.

Each declaration type leaves a slightly different state at the end of the parse phase. That small difference is the source of every hoisting trick. The table below summarises the states.

DeclarationState after parse phaseEffect at execute time
var xname created, value = undefinedread before line = undefined (no error)
let yname created, value = uninitialised (TDZ)read before line throws ReferenceError
const zname created, value = uninitialised (TDZ)read before line throws ReferenceError
function f() {}name created, value = full functioncallable from any line in the scope
var f = function () {}name created, value = undefinedcall before line throws TypeError (undefined is not a function)
class C {}name created, value = uninitialised (TDZ)use before line throws ReferenceError
sequenceDiagram
  participant Parse as Parse phase
  participant Exec as Execution phase
  Parse->>Exec: scan body, set up bindings
  Note over Parse: var x = undefined
let y = TDZ
const z = TDZ
function f = whole fn
class C = TDZ Exec->>Exec: line 1 - read x -> undefined Exec->>Exec: line 2 - read y -> ReferenceError Exec->>Exec: line 3 - call f() -> works Exec->>Exec: line 4 - x = 5 - assign now Exec->>Exec: line 5 - let y = 10 - now safe
Two-pass timeline - what each declaration looks like after parse, what it does at execute

6a. var hoisting - declared + undefined

The classic. The declaration moves to the top of the function scope; the assignment stays where it is.

hoist-var.js
// var hoisting - declaration goes up, assignment stays
console.log(retries);   // undefined - NOT an error
var retries = 3;
console.log(retries);   // 3

// what V8 actually treats it as:
//   var retries;          (parse - hoisted)
//   console.log(retries); // undefined
//   retries = 3;          (execute)
//   console.log(retries); // 3
Source you wrote console.log(retries); var retries = 3; console.log(retries); After parse phase var retries; // hoisted console.log(retries); retries = 3; console.log(retries); first log = undefined second log = 3
var hoisting - the declaration is split from the assignment
Drill 6a.1 Convert this snippet from var to let and predict the new output before running it. What error message does Node print?

6b. let / const hoisting - the TDZ

With let and const, the name is hoisted but stays uninitialised. The window between the start of the block and the line that actually runs the declaration is called the Temporal Dead Zone. Reading the name inside that window throws a ReferenceError. The TDZ exists on purpose - it forces top-to-bottom thinking and catches "I used this before I declared it" bugs.

hoist-let.js
// let / const - hoisted but in TDZ until the declaration line runs
function show() {
  // console.log(timeout); // ReferenceError: Cannot access 'timeout' before initialization
  let timeout = 30; // TDZ ends here
  console.log(timeout); // 30
}

show();
Temporal Dead Zone block starts { read timeout -> ERROR read timeout -> ERROR let timeout = 30; (end TDZ) TDZ is the gap between block start and declaration After the declaration read timeout -> 30 timeout = 60 -> OK read timeout -> 60 name is bound + writable const reads still work but writes throw TypeError
TDZ - the gap between "name exists" and "name initialised"
Drill 6b.1 Write a function that intentionally reads a let binding inside the TDZ and catches the ReferenceError with a try / catch. Print the error message. Why does this matter when reading code from a teammate?

6c. Function declaration hoisting - whole function moves up

A function declaration (the statement form, not the expression form) is hoisted in its entirety - name AND body. You can call it before its source line. This is one of the few cases where "use before declare" is intentional and idiomatic.

hoist-fn-decl.js
// function declaration - fully hoisted, callable from line 1
console.log(double(7)); // 14 - works

function double(n) {
  return n * 2;
}

console.log(double(9)); // 18 - also works
flowchart TB
  P[Parse phase] --> H[hoist whole function body]
  H --> B[binding double = function definition]
  B --> E[Execute phase]
  E --> C1[line 1: call double 7  -> 14]
  E --> C2[line 3: source declaration line - no-op]
  E --> C3[line 7: call double 9 -> 18]
Function declaration - parse phase already has the full body

6d. Function expression hoisting - only the binding

A function expression assigned to a var only hoists the variable name. The function body is part of the assignment, which happens in the execute phase. So calling it before the assignment line throws a TypeError: ... is not a function - because the binding is undefined at that point.

hoist-fn-expr.js
// function expression - only the var name is hoisted
// console.log(triple(7)); // TypeError: triple is not a function

var triple = function (n) {
  return n * 3;
};

console.log(triple(9)); // 27 - works after the assignment
After parse phase var triple = undefined; // the var name is here, // but the function body is // NOT - it lives in the // assignment expression Before assignment triple(7) -> TypeError After assignment triple(7) -> 21 binding is now the function
Function expression - the body is bound only after the assignment line runs
Drill 6d.1 Take the function expression above and change var to let. Predict the error you get if you call triple(7) on line 1, then run and compare.

6e. Class hoisting - declared but in TDZ

Class declarations behave like let - the name is hoisted but unusable until the declaration line is reached. This is intentional. ES2015 designers wanted classes to feel like "no hoisting" so the syntax reads top-to-bottom like Java or C#.

hoist-class.js
// class hoisting - TDZ until the declaration line
// const card = new ProductCard('tta-001');  // ReferenceError

class ProductCard {
  constructor(sku) { this.sku = sku; }
}

const card = new ProductCard('tta-001'); // OK after the line
console.log(card.sku);

6f. Hoisting inside an if block

Where does a var inside an if block actually live? The function. Where does a let live? The block. Same source - different scopes for these two keywords.

hoist-if-block.js
// var inside if leaks to function; let inside if stays in block
function demo() {
  if (true) {
    var a = 1;
    let b = 2;
  }
  console.log(a);            // 1   - var leaked to function
  // console.log(b);     // ReferenceError - let stayed in block
}
demo();
flowchart TB
  FN[function demo - scope] --> IFB[if block - scope]
  IFB --> VAR1[var a - hoisted UP to fn scope]
  IFB --> LET1[let b - stays in block scope]
  FN --> READ_A[outside if: read a -> 1]
  FN --> READ_B[outside if: read b -> ReferenceError]
Hoisting inside if - var moves up to the function, let stays put

6g. Hoisting inside a for loop

A loop variable declared with var is just one binding for the whole loop - and after the loop, it's still visible. A let loop variable creates a new binding per iteration - and disappears after the loop. The behaviour of closures captured inside the loop changes accordingly (we saw the timeout puzzle earlier).

hoist-for-loop.js
function demo() {
  for (var i = 0; i < 3; i++) { /* ... */ }
  console.log(i);   // 3 - var i leaked OUT of the loop

  for (let j = 0; j < 3; j++) { /* ... */ }
  // console.log(j); // ReferenceError - let j stayed inside
}
demo();

6h. Hoisting in arrow functions

Arrow functions are always expressions. They are never hoisted as a whole - only their binding hoists, the same way a function expression does. There is no such thing as an arrow function declaration. This is why old codebases that mix arrows and declarations sometimes show inconsistent call-before-declare behaviour.

hoist-arrow.js
// arrows are always expressions
// add(2, 3); // ReferenceError (let) or TypeError (var)

const add = (a, b) => a + b;

console.log(add(2, 3)); // 5 - after the const line
var name name + undefined let / const name + TDZ function decl full body var f = function name + undefined const arrow name + TDZ class name + TDZ six declaration kinds, three end-states undefined / TDZ / full body - that's the whole table
Hoisting summary - six declaration kinds, three possible parse-phase states
Drill 6 - exit checks
  1. Predict: console.log(typeof x); var x = 1; - what prints?
  2. Predict: console.log(typeof y); let y = 1; - what prints?
  3. Predict: greet(); function greet() { console.log('hi'); } - what prints?
  4. Predict: greet(); const greet = () => console.log('hi'); - what error and why?
  5. Write a 6-line snippet that uses var hoisting on purpose to print undefined instead of a ReferenceError. Add a comment that explains the trick to a future teammate.

07Literals + null vs undefined ch 3 - 19_Identifier to 23_null_undefined

A literal is a value you spell out directly in source code, as opposed to constructing it with a function or operator. 42 is a number literal. 'hi' is a string literal. JavaScript has eleven literal forms across its value types - including a few that did not exist when the original chapter 3 lecture was first recorded.

Every literal form

TypeLiteral exampleNotes
number42, 3.14, 1e3, 0xff, 0b101, 0o17decimal, exponential, hex, binary, octal
string'hi', "hi"single or double quotes are equivalent
template`hi ${name}`backticks, interpolation with ${ }
booleantrue, falsetwo values, that's it
nullnullintentional "no value here"
undefinedundefineduninitialised binding (not really a literal in old specs)
BigInt9007199254740993narbitrary-precision integer, trailing n
SymbolSymbol('id')not a literal syntactically, included for completeness
RegExp/^abc/islash-delimited, optional flags after
object{ name: 'tta' }curly braces, key:value pairs
array[1, 2, 3]square brackets, comma-separated
literals.js
// every literal form, one line each
const price   = 199;                  // number literal
const hex     = 0xff;                 // 255 in hex
const name    = 'tta-001';            // string
const label   = `item: ${name}`;      // template
const ok      = true;                // boolean
const empty   = null;                // null - intentional absent
let   later;                          // undefined - not yet set
const big     = 10n;                  // BigInt
const id      = Symbol('id');         // Symbol
const re      = /^Rs \d+$/;             // RegExp
const obj     = { sku: name, price }; // object literal
const list    = [1, 2, 3];            // array literal

console.log(typeof price, typeof name, typeof empty, typeof later);
// -> 'number' 'string' 'object' 'undefined'
flowchart LR
  V[every value] --> P{primitive?}
  P -- yes --> PRIM[number / string / boolean / null / undefined / symbol / bigint]
  P -- no --> OBJ[object / array / function / class instance]
  PRIM --> COPY[copied by VALUE]
  OBJ --> REF[passed by REFERENCE]
Two universes of values - primitives copy by value, objects share by reference

Why typeof null is 'object'

One of the oldest and most famous bugs in JavaScript. In the original 1995 implementation, every value was tagged with a 1-byte type code. null was represented as the all-zero pointer, which had a type tag of zero. The type tag for objects was also zero. So typeof null walked the same path as typeof someObject and returned 'object'. The bug was reported, a fix was drafted, and it was rejected as a breaking change. The bug is now part of the spec - permanently.

1995 - one byte tag per value null tag = 0x00 (zero pointer) looks like an object slot objects tag = 0x00 too same path through type-check result: typeof null === 'object' spec-frozen, never fixed, remember it instead use x === null to check, not typeof
typeof null is 'object' - a 1995 bug that became spec

null vs undefined - the distinction

Both mean "no value", but they signal different intent:

nullundefined
Set byyou, deliberatelythe engine, when a binding has no value yet
typeof returns'object' (legacy bug)'undefined'
Default param fires?no - null is a "real" valueyes - default activates for undefined only
JSON.stringifykeeps as nulldrops the key
Equality null == undefinedtrue (loose), false (strict)
Recommended use"intentionally empty" - empty cart, no parent, deleted row"not set yet" - usually a bug if you assign it on purpose
flowchart LR
  V[a value slot]
  V --> UND[undefined]
  V --> NUL[null]
  UND --> UM[meaning: engine has not set me]
  NUL --> NM[meaning: human has set me to empty on purpose]
  UM --> FIX[likely a bug if observed]
  NM --> OK[intentional, expected]
null vs undefined - same shape, different stories
Drill 7.1
  1. What does typeof null return? Why?
  2. What does typeof undeclaredVar return for a name that was never declared at all?
  3. What does typeof [] return? Why is the answer arguably wrong?
  4. Write a tiny snippet that uses JSON.stringify on { a: null, b: undefined } and inspect the output.

08Equality - == vs === vs Object.is ch 3 - 24_equla_triequal, 25_IQ

Three operators that look almost identical and behave very differently. Pick the wrong one in an assertion and your test passes when it shouldn't (or fails when it shouldn't). The short rule: in test code, always use ===. The long rule needs a decision tree - which is the next diagram.

Three operators, three behaviours

== loose=== strictObject.is
Coerces types?yes - many rulesnono
0 == falsetruefalsefalse
'1' == 1truefalsefalse
null == undefinedtruefalsefalse
NaN === NaNfalsefalsetrue
+0 === -0truetruefalse
Recommendednever in new codeuse this 99% of the timewhen NaN or signed zero matters
flowchart TD
  S[a == b] --> T{types match?}
  T -- yes --> STRICT[then behave like ===]
  T -- no --> A{is one null and other undefined?}
  A -- yes --> OK[result: true]
  A -- no --> B{is one number, other string?}
  B -- yes --> CONV1[convert string to number, recurse]
  B -- no --> C{is one boolean?}
  C -- yes --> CONV2[convert boolean to number, recurse]
  C -- no --> D{is one object, other primitive?}
  D -- yes --> CONV3[object -> primitive via valueOf / toString, recurse]
  D -- no --> FALSE[result: false]
Decision tree for == loose equality - the rules behind every coercion gotcha

Five famous gotchas, one snippet

equality-gotchas.js
// each line is a classic interview puzzle - try them at a REPL
console.log(0 == false);          // true  - boolean -> number, 0 == 0
console.log('' == 0);             // true  - string '' -> 0, 0 == 0
console.log(null == undefined);   // true  - one of the special cases
console.log([] == ![]);            // true  - !![] -> false -> 0; [] -> '' -> 0
console.log(NaN === NaN);          // false - NaN is never equal to anything, even itself
console.log(Object.is(NaN, NaN));    // true  - Object.is exists for this case
[] == ![] - step by step ![] [] is truthy !truthy false false -> 0 number coerce [] .toString() '' empty string '' -> 0 number coerce 0 == 0 -> true
[] == ![] coerces both sides to 0 - that's why the loose equality returns true

Object.is - the third equality

Object.is behaves like === except for two corner cases: Object.is(NaN, NaN) is true, and Object.is(+0, -0) is false. Use it when you need to detect NaN without a separate Number.isNaN check, or when you genuinely care that a calculated zero kept its sign.

Rule for test code. Always reach for === first. If you find yourself wanting ==, you almost certainly want === null with the optional chaining operator from section 15. Object.is is reserved for the two corner cases above.
Drill 8.1
  1. Predict: '' == false, '0' == false, '' == '0'.
  2. Predict: 1 == [1] - true or false? Trace the coercion.
  3. Why does Playwright's expect use deep-equality for objects rather than ===?
  4. Write a helper looseEquals(a, b) in 5 lines that wraps == but logs the coercion path to console.

09Assignment operators ch 4 - 26 / 27_Assignment_Operators

The single equals = binds a value to a name. Every other assignment operator is the same plus arithmetic - x += 1 is x = x + 1. The interesting ones are the three logical-assigned operators that arrived with ES2021: ??=, ||=, and &&=. They only assign if the left side passes a specific truthy / nullish check.

Every assignment operator

OperatorEquivalentExample
=bindlet n = 5;
+=n = n + ...n += 3; // 8
-=n = n - ...n -= 2; // 6
*=n = n * ...n *= 4; // 24
/=n = n / ...n /= 3; // 8
%=n = n % ...n %= 5; // 3
**=n = n ** ...n **= 2; // 9
&&=assign if left is truthyx &&= 'go';
||=assign if left is falsyx ||= 'default';
??=assign if left is nullishx ??= 'fallback';
Verbose form n = n + 1; n = n * 2; x = x || 'fb'; x = x ?? 'fb'; repeats the name twice Shorthand n += 1; n *= 2; x ||= 'fb'; x ??= 'fb'; read once, write once
Verbose vs shorthand - same effect, half the typing
assign-ops.js
// arithmetic shorthand
let count = 10;
count += 5;   // 15
count *= 2;   // 30
count %= 7;   // 2
count **= 3;  // 8

// logical-assigned (ES2021)
let config = { retries: 0 };
config.retries ||= 3;     // 3 - because 0 is falsy
config.retries ??= 5;     // stays 3 - because 3 is not nullish
config.debug   ??= false; // false - because undefined is nullish

console.log(count, config);
// 8 { retries: 3, debug: false }
Beware ||= on zero. Because 0 is falsy, config.retries ||= 3 overwrites a deliberate 0. Use ??= when zero or empty string is a valid value.
Drill 9.1
  1. Predict what config.retries ends up as in the snippet above. Verify by running.
  2. Rewrite a long-winded if (!x) x = 'default' using both ||= and ??=. When do the two differ?
  3. What does let s = 'abc'; s += 1; evaluate to? Why?

10Comparison operators ch 4 - 28_Comparsion_Operators

Six operators that return a boolean. Four for ordering (<, <=, >, >=) and two for inequality (!= and !==). Equality we already covered in section 8. The ordering operators have their own coercion rules - strings compare lexicographically, numbers compare numerically, and when types differ the right side is converted to the type of the left.

OperatorRead asCoerces?Example
<less thanyes2 < 3 -> true
<=less or equalyes3 <= 3 -> true
>greater thanyes'b' > 'a' -> true
>=greater or equalyes'5' >= 5 -> true
!=loose not equalyes1 != '1' -> false
!==strict not equalno1 !== '1' -> true
flowchart LR
  CMP[a OP b] --> SAME{same type?}
  SAME -- yes --> DIRECT[compare directly]
  SAME -- no --> STR{both strings?}
  STR -- yes --> LEX[lexicographic compare]
  STR -- no --> NUM[convert both to number, compare]
  NUM --> NAN{either NaN?}
  NAN -- yes --> FALSE[result: false]
  NAN -- no --> OK[numeric compare]
Comparison decision tree - strings lex-compare, numbers numeric, NaN always false
compare-ops.js
// numeric vs lexicographic vs mixed
console.log(5 < 10);          // true  - both numbers
console.log('5' < '10');      // false - '1' < '5' as STRINGS
console.log('5' < 10);        // true  - mixed, '5' -> 5, then 5 < 10
console.log('a' < 'b');        // true  - codepoint compare
console.log('A' < 'a');        // true  - 'A' = 65, 'a' = 97
console.log(NaN < 0);          // false - NaN compares false to everything
console.log(NaN > 0);          // false - including to 0 and to itself
'5' < '10' - the lex trap '5' code 53 '10' first char '1' = 49 53 < 49 ? false -> '5' is NOT < '10' parse numerics with Number() before comparing
String compare - lex order means '5' > '10' because '5' > '1'
Drill 10.1
  1. Predict '2' < '10' < '100' step by step (recall comparisons are left-associative).
  2. Sort the array ['10', '2', '1'] with Array#sort and observe the surprising result. Fix it with a custom comparator.
  3. What does NaN !== NaN return? Why is this the standard NaN-detection idiom before Number.isNaN?

11Logical operators ch 4 - 29_Logical_Operators

Three operators: && (and), || (or), ! (not). In JavaScript they return one of the operands, not necessarily a boolean - this is the short-circuit behaviour. a && b returns a if it's falsy, otherwise b. a || b returns a if it's truthy, otherwise b. !a always returns a real boolean.

Truth table - the values JS actually returns

ExpressionResultWhy
true && 'yes''yes'left truthy -> return right
false && 'yes'falseleft falsy -> return left, short-circuit
'a' || 'b''a'left truthy -> return left, short-circuit
0 || 'b''b'left falsy -> return right
!''true'' is falsy, ! flips to true
!!{}true!! is the classic "to-boolean" trick
Short-circuit evaluation a && b if (a falsy) return a if (a truthy) eval b return b use for short-circuit DO: user && user.save() a || b if (a truthy) return a if (a falsy) eval b return b use for fallback / default: name || 'guest'
Short-circuit semantics - && stops at first falsy, || stops at first truthy
logical-ops.js
// short-circuit returns the operand, not a boolean
const user = { name: 'pia' };

// guard + access
const letter = user && user.name[0];   // 'p'

// fallback
const label  = user.role || 'guest';    // 'guest'

// to-boolean coercion
const hasName = !!user.name;            // true

// short-circuit side effect
user && console.log('user is present'); // fires
null && console.log('never fires');     // skipped

The 8 falsy values

Every other value is truthy. Memorise this list - it powers ||, !, and if:

false 0 -0 0n '' null undefined NaN

Sometimes-surprising truthy values: '0' (string), [], {}, 'false' (string), function () {}.

Drill 11.1
  1. Predict: '' || 0 || null || 'last' - what value comes out?
  2. Predict: true && 'a' && 0 && 'c' - what value comes out?
  3. Refactor if (user) { user.save(); } into a one-liner using &&.
  4. Why is !!x a common idiom for converting any value to a real boolean?

12String operators ch 4 - 30_String_Operators

Strings have two operator forms that matter at this stage: the plus operator + (concatenation) and the template literal (interpolation). Plus is the classic way; template literals are the modern preferred form. Avoid the older String.prototype.concat method - it offers nothing the operators don't.

Plus and template literals side by side

strings.js
// plus operator - concatenation
const name = 'tta';
const batch = 2026;
const a = 'hello ' + name + ' batch ' + batch;
// 'hello tta batch 2026' - + coerces number to string when one side is string

// template literal - interpolation, multi-line, no escapes
const b = `hello ${name} batch ${batch}`;
// same string, cleaner

// multi-line
const tpl = `
<p>Welcome ${name}!</p>
<p>Batch ${batch}.</p>
`;

// pitfall: + with mixed types
console.log('2' + 3);  // '23' - number coerced to string
console.log(2 + '3');  // '23' - same
console.log(2 + 3 + '4'); // '54' - left to right, 2+3=5, then 5+'4'
+ concat 'Rs ' + n + '/-' 'Rs ' + (a+b) + '/-' parens needed for math noisy with many parts Template literal `Rs ${n}/-` `Rs ${a + b}/-` math inside expressions recommended in new code
+ vs template - prefer template literals for readability
flowchart LR
  A[2 + 3 + '4'] --> B[step 1: 2 + 3 = 5  - both numbers, add]
  B --> C[step 2: 5 + '4' = '54'  - one side string, concat]
  D[2 + '3' + 4] --> E[step 1: 2 + '3' = '23' - concat]
  E --> F[step 2: '23' + 4 = '234' - concat continues]
+ trace - the FIRST string flips every later + into concatenation
Use templates for selectors too. Playwright locators read better when you template the variable into the selector string: page.locator(`[data-sku="${sku}"]`) beats page.locator('[data-sku="' + sku + '"]').
Drill 12.1
  1. Predict: '' + 1 + 2 vs 1 + 2 + '' - same or different?
  2. Convert a four-part concatenation 'Rs ' + base + ' + ' + tax + ' = ' + total into a template literal.
  3. What does `${[1,2,3]}` evaluate to? Why?

13Ternary operator ch 4 - 31_Ternary_Operators

The only three-operand operator in JavaScript. cond ? a : b evaluates cond; if truthy, returns a; otherwise returns b. Ternary is a values-only construct - both branches must produce a value, and the result is a value (not a statement). Use it inside expressions; do not abuse it for control flow.

flowchart LR
  COND[cond expression] --> Q{truthy?}
  Q -- yes --> A[evaluate a, return a]
  Q -- no  --> B[evaluate b, return b]
  A --> R[result of the whole expression]
  B --> R
Ternary - same shape as if/else, but expression-shaped
ternary.js
// ternary as an inline if
const age = 17;
const tag = age >= 18 ? 'adult' : 'minor';
console.log(tag); // 'minor'

// nested - readable when wrapped, ugly when not
const grade = (score) =>
  score >= 90 ? 'A'
  : score >= 75 ? 'B'
  : score >= 50 ? 'C'
  :                'F';

console.log(grade(82)); // 'B'
good - one decision age >= 18 ? 'adult' : 'minor' small, clear use in JSX, return, template, assignment bad - nested with side effects cond ? doX() : doY() ? doZ() : null control flow inside expressions refactor to if/else or a small function
Ternary - love it for one decision, avoid for chained side effects
Drill 13.1
  1. Refactor a 6-line if/else if/else ladder for a 4-grade scoring rubric into a single nested ternary.
  2. Why is the ternary expression preferred inside return statements when the function is a one-liner?

14Type operators ch 4 - 31_Type_Operators

Five operators that act on the type or membership of a value, not its arithmetic value. typeof returns a string. instanceof walks the prototype chain. in checks property existence. delete removes a property. void is a niche bookmarklet-era operator that forces an expression to evaluate to undefined.

OperatorReturnsWhat it testsExample
typeof xstringprimitive type or 'object' / 'function'typeof 5 -> 'number'
x instanceof Cbooleanis C.prototype in x's chain[] instanceof Array
k in objbooleandoes obj have key k (including inherited)'sku' in product
delete obj.kbooleanremoves the property, returns successdelete cart.coupon
void exprundefinedevaluates expr, discards resultvoid 0 -> undefined

The typeof grid - all nine results

typeof returns one of these 9 strings typeof undefined 'undefined' typeof null 'object' (BUG) typeof 5 'number' typeof 'hi' 'string' typeof true 'boolean' typeof Symbol() 'symbol' typeof 1n 'bigint' typeof () => {} 'function' typeof [] 'object' arrays + objects + dates + maps all return 'object' use Array.isArray, instanceof, or === null to disambiguate
Nine possible typeof results - memorise the order, especially null and array
type-ops.js
// typeof - safe even on undeclared names
console.log(typeof undeclared);   // 'undefined' - no error

// instanceof - prototype chain walk
console.log([] instanceof Array);   // true
console.log([] instanceof Object);  // true - Array extends Object

// in - includes inherited keys
const cart = { sku: 'tta-001', qty: 2 };
console.log('sku' in cart);          // true
console.log('toString' in cart);     // true - inherited from Object.prototype

// delete - removes own property
delete cart.qty;
console.log(cart);                  // { sku: 'tta-001' }

// void - force undefined result
console.log(void 0);                // undefined
console.log(void runForSideEffect?.()); // undefined regardless of return
Drill 14.1
  1. Predict typeof for: NaN, Infinity, null, an arrow function, an array.
  2. Distinguish: when do you use typeof x === 'undefined' vs x === undefined?
  3. Why is Array.isArray(x) preferred over x instanceof Array in browser code that talks to iframes?

15Nullish coalescing + optional chaining ch 4 - 32_Null_Optinal_Value

Two operators added in ES2020 that quietly transformed everyday JavaScript code. Optional chaining ?. short-circuits a property access if the left side is null or undefined. Nullish coalescing ?? picks the right side only when the left side is nullish. Together they replace dozens of guard clauses you used to write with && and ||.

Optional chaining - the safe dot

optional-chain.js
const cart = { items: [{ sku: 'tta-001', price: 199 }] };
const empty = {};

// before ?. (old style)
const a = cart && cart.items && cart.items[0] && cart.items[0].price;
// 199

// after ?. - same intent, far cleaner
const b = cart?.items?.[0]?.price;        // 199
const c = empty?.items?.[0]?.price;       // undefined - no error

// optional method call
const d = cart.checkout?.();                // undefined if checkout is not a function

// optional via dynamic key
const key = 'items';
const e = cart?.[key]?.length;            // 1
flowchart LR
  L[left] --> Q{is left null or undefined?}
  Q -- yes --> UNDEF[short-circuit, return undefined]
  Q -- no  --> R[evaluate right side normally]
  R --> VAL[return the property / call result]
Optional chaining - short-circuits to undefined when left is nullish

Nullish coalescing - the keep-zero default

|| picks the right side when the left is falsy - which includes 0, '', and false. ?? picks the right side only when the left is nullish - null or undefined. For numeric and string defaults where 0 or '' are legal, ?? is the correct choice.

nullish.js
// || treats 0 and '' as "not given"
const retries1 = 0 || 3;            // 3 - 0 was falsy
const label1   = '' || 'guest';      // 'guest'

// ?? respects 0 and ''
const retries2 = 0 ?? 3;            // 0 - 0 is not nullish
const label2   = '' ?? 'guest';      // '' - '' is not nullish

const cfg = { timeout: 0, tag: null };
const timeout = cfg.timeout ?? 30;       // 0 - intentional zero, kept
const tag     = cfg.tag ?? 'untagged';     // 'untagged' - null is nullish
?? vs || - what counts as missing? || (falsy fallback) picks default for ANY falsy 0, '', false, null, undef, NaN eats valid 0 and '' avoid when 0 is a real value price.discount || 0 <-- ok ?? (nullish fallback) picks default ONLY for nullish null, undefined keeps 0, '', false intact use for counts, prices, flags price.discount ?? 0
?? vs || - the line between "absent" and "falsy"

Combining ?. and ??

The two operators are designed to compose. The pattern below shows up dozens of times in a typical Playwright suite:

combo.js
// safely read a deep value, default if missing
const price = order?.items?.[0]?.discount?.value ?? 0;

// before ES2020 you needed something like:
//   const price = (order && order.items && order.items[0] && order.items[0].discount && order.items[0].discount.value !== undefined)
//     ? order.items[0].discount.value
//     : 0;
Avoid ?. for required values. If a property MUST be present for the code to make sense, do not paper over a bug with ?.. Throw early with a clear message instead. Optional chaining is for genuinely optional access.
Drill 15.1
  1. Replace this snippet with one that uses ?. + ??: const n = obj && obj.user && obj.user.name ? obj.user.name : 'anon';
  2. Predict: 0 ?? 'def' vs 0 || 'def' - which returns what?
  3. What does cart?.items?.length === 0 evaluate to when cart is undefined?

16Spread / rest / comma / exponentiation ch 4 - extras

Four smaller operators that round out chapter 4. Spread and rest share the ... token but appear on opposite sides - one expands a value, the other collects. The comma operator is rare but does turn up in for-loop heads. Exponentiation ** replaces Math.pow in modern code.

Spread / rest - same token, opposite roles

FormRoleExample
...arr inside a call or arrayspread - expand into elementsMath.max(...nums)
...rest inside parameter listrest - collect remainingfunction f(a, ...rest)
{...obj}object spread - shallow copyconst c = {...a, ...b}
[...arr]array spread - shallow copyconst c = [...a, ...b]
spread-rest.js
// spread - expand into args / elements
const nums = [3, 7, 2, 9];
console.log(Math.max(...nums)); // 9

const a = [1, 2], b = [3, 4];
const c = [...a, ...b, 5]; // [1, 2, 3, 4, 5]

// rest - collect remaining args
function log(level, ...parts) {
  console.log(`[${level}]`, ...parts);
}
log('info', 'started', 42, { ok: true });

// object spread - shallow merge
const base     = { retries: 3, timeout: 30 };
const override = { timeout: 60 };
const final    = { ...base, ...override }; // { retries: 3, timeout: 60 }
SPREAD (...arr) f(...args) [1, ...arr, 99] {...a, ...b} on the CALL / LITERAL side expands one value into many REST (...name) function f(...args) { } const [first, ...rest] = arr const { a, ...rest } = obj on the DECLARATION side collects many values into one
Spread expands, rest collects - same token, opposite directions

Exponentiation

** is right-associative and produces a number. Use it instead of Math.pow.

power.js
console.log(2 ** 8);    // 256
console.log(2 ** 3 ** 2); // 512 - right-associative: 2 ** (3 ** 2) = 2 ** 9
console.log(9 ** 0.5);  // 3   - square root via fractional exponent
flowchart LR
  A[2 ** 3 ** 2] --> B[right associative]
  B --> C[evaluate 3 ** 2 first = 9]
  C --> D[then 2 ** 9 = 512]
  E[2 ** 8] --> F[straight power: 256]
  G[9 ** 0.5] --> H[fractional = sqrt: 3]
Exponentiation - right-associative, so 2 ** 3 ** 2 chains the inner one first

Comma operator

Evaluates each expression left-to-right and returns the last value. Rare but turns up in for loop heads.

comma.js
// comma operator - returns the LAST expression
const x = (1, 2, 3);     // 3

// for-loop with two counters
for (let i = 0, j = 10; i < j; i++, j--) {
  // runs while i < j; both counters tick each pass
}
Drill 16.1
  1. Use spread to clone an array; mutate the clone; verify the original is untouched.
  2. Predict: Math.max(...[3, NaN, 5]) - what does it return?
  3. What does (2, 3, 4) ** 2 evaluate to? Trace the steps.

17Interview puzzles - chapters 1-4 ch 3 - 25_IQ + chapter mix

Ten classic predict-the-output puzzles drawn from chapter 3's interview-question file and extended across all four chapters. Try to predict every line without running it. Then run it in node and read the answer reveal.

1typeof of nothing

console.log(typeof undeclared);
console.log(typeof null);
console.log(typeof NaN);
Reveal answer

'undefined' - typeof is safe on undeclared names. Then 'object' - the famous 1995 bug we covered in section 7. Then 'number' - NaN really is a number-typed value despite the name.

go to literals section

2Hoist with var

console.log(a);
var a = 5;
console.log(a);
Reveal answer

First log: undefined. Second log: 5. var a is hoisted as a declaration only; the assignment runs on line 2.

go to hoisting section

3TDZ with let

console.log(a);
let a = 5;
Reveal answer

ReferenceError: Cannot access 'a' before initialization. The name is hoisted but lives in the TDZ until the let line runs.

go to hoisting section

4Function decl before line

greet();
function greet() {
  console.log('hi');
}
Reveal answer

hi. Function declarations are hoisted in full - the body is available before the source line.

go to hoisting section

5Arrow before line

greet();
const greet = () => console.log('hi');
Reveal answer

ReferenceError on line 1 because greet is in the TDZ. Even if you changed const to var, you'd get TypeError: greet is not a function instead.

go to hoisting section

6The classic loop trap

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
Reveal answer

Prints 3 three times. All three callbacks share the SAME var i, which has the value 3 by the time they run. Change var to let to get 0 1 2.

var i one binding let i fresh per pass
go to var/let/const section

7Empty array == false

console.log([] == false);
console.log([] === false);
Reveal answer

First: true. Both sides coerce to 0 (empty array -> '' -> 0; false -> 0) then 0 == 0. Second: false - strict equality checks types first, and array is not boolean.

go to equality section

8Math with strings

console.log(1 + '2' + 3);
console.log('1' + 2 + 3);
console.log(1 + 2 + '3');
Reveal answer

'123' - first 1 + '2' coerces to string. Then '123' - same on the left. Then '33' - 1 + 2 = 3 first, then 3 + '3'.

go to string operators

9Nullish vs falsy default

console.log(0 || 'a');
console.log(0 ?? 'a');
console.log('' || 'b');
console.log('' ?? 'b');
Reveal answer

'a' (zero is falsy). 0 (zero is not nullish). 'b' (empty string is falsy). '' (empty string is not nullish). This is exactly why ?? exists.

go to nullish section

10Optional chaining + zero

const cfg = { retries: 0 };
console.log(cfg.retries || 3);
console.log(cfg.retries ?? 3);
console.log(cfg?.retries ?? 3);
Reveal answer

3 - || overrode the intended 0. 0 - ?? kept the zero. 0 - chaining stops doing anything once we know cfg is defined; the result of the read is 0, which is not nullish, so the default is skipped.

go to nullish section

18What's next

Five more deep pages will follow this one, each covering a chapter group. The hub page tracks status. Until the next page is ready, the existing TTA pages below are the closest running-code targets.