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.
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 monthsSame .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.
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 objectsFrom 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 timesfunctionsquare(x) {
returnx * x;
}
lettotal = 0;
for (leti = 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
Run the program with node --trace-opt --trace-deopt hot.js and find the iteration count where V8 logs the promotion line.
Then call square('5') from the loop occasionally. Re-run with the same flags - what does V8 print and why?
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 testThe 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.
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 runtimeglobalThis.TTA = { batch: 'playwright-2026' };
console.log(globalThis.TTA.batch);
// works in node AND in a browser tab
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 lineconstsku = 'tta-001'; // trailing comment also works/*
block comment - spans
multiple lines, can wrap
a paragraph of intent
*/constprice = 199;
/**
* JSDoc - typed comment for IDEs
* @param {string} name - learner name
* @returns {string} greeting
*/functionhi(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.
Identifier
Legal?
Why
price
yes
letter start, all letters
_skuName
yes
underscore start is fine
$el
yes
dollar start is fine (used a lot in jQuery + Playwright helpers)
price1
yes
digit allowed after the first character
1price
no
cannot start with a digit
let
no
reserved keyword
class
no
reserved keyword
my price
no
spaces are not allowed in identifiers
my-price
no
hyphens are subtraction, not name characters
my_price
yes
underscores are fine
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:
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
Property
var
let
const
Scope
function
block
block
Hoisting
declared + initialised to undefined
declared but TDZ
declared but TDZ
Re-declare in same scope
yes
no
no
Re-assign
yes
yes
no
Must initialise on declare
no
no
yes
Becomes a global property
yes (in scripts)
no
no
Recommended for new code
no
when reassigning
default 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 everythingBubble 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 functionfunctionmain() {
if (true) {
varteamSize = 12;
}
console.log(teamSize); // 12 - var is function-scoped, not block
}
main();
let-block-scope.js
// let - trapped inside the nearest { } blockfunctionmain() {
if (true) {
letteamSize = 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 mutateconstlimit = 100;
// limit = 200; // TypeError: Assignment to constant variableconstqueue = [1, 2];
queue.push(3); // OK - mutating the array, not the bindingconsole.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, 3for (vari = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10);
}
// with let - prints 0, 1, 2for (letj = 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
Predict: let a = 1; let a = 2; in the same scope - error or fine?
Take a var-driven double-loop matrix builder you've written and convert it to let + const. Note any behaviour change.
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:
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.
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.
Declaration
State after parse phase
Effect at execute time
var x
name created, value = undefined
read before line = undefined (no error)
let y
name created, value = uninitialised (TDZ)
read before line throws ReferenceError
const z
name created, value = uninitialised (TDZ)
read before line throws ReferenceError
function f() {}
name created, value = full function
callable from any line in the scope
var f = function () {}
name created, value = undefined
call 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 staysconsole.log(retries); // undefined - NOT an errorvarretries = 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
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 runsfunctionshow() {
// console.log(timeout); // ReferenceError: Cannot access 'timeout' before initializationlettimeout = 30; // TDZ ends hereconsole.log(timeout); // 30
}
show();
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 1console.log(double(7)); // 14 - worksfunctiondouble(n) {
returnn * 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 functionvartriple = function (n) {
returnn * 3;
};
console.log(triple(9)); // 27 - works after the assignment
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'); // ReferenceErrorclassProductCard {
constructor(sku) { this.sku = sku; }
}
constcard = newProductCard('tta-001'); // OK after the lineconsole.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 blockfunctiondemo() {
if (true) {
vara = 1;
letb = 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
functiondemo() {
for (vari = 0; i < 3; i++) { /* ... */ }
console.log(i); // 3 - var i leaked OUT of the loopfor (letj = 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)constadd = (a, b) => a + b;
console.log(add(2, 3)); // 5 - after the const line
Hoisting summary - six declaration kinds, three possible parse-phase states
Drill 6 - exit checks
Predict: console.log(typeof x); var x = 1; - what prints?
Predict: console.log(typeof y); let y = 1; - what prints?
Predict: greet(); function greet() { console.log('hi'); } - what prints?
Predict: greet(); const greet = () => console.log('hi'); - what error and why?
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
Type
Literal example
Notes
number
42, 3.14, 1e3, 0xff, 0b101, 0o17
decimal, exponential, hex, binary, octal
string
'hi', "hi"
single or double quotes are equivalent
template
`hi ${name}`
backticks, interpolation with ${ }
boolean
true, false
two values, that's it
null
null
intentional "no value here"
undefined
undefined
uninitialised binding (not really a literal in old specs)
BigInt
9007199254740993n
arbitrary-precision integer, trailing n
Symbol
Symbol('id')
not a literal syntactically, included for completeness
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.
typeof null is 'object' - a 1995 bug that became spec
null vs undefined - the distinction
Both mean "no value", but they signal different intent:
null
undefined
Set by
you, deliberately
the engine, when a binding has no value yet
typeof returns
'object' (legacy bug)
'undefined'
Default param fires?
no - null is a "real" value
yes - default activates for undefined only
JSON.stringify
keeps as null
drops the key
Equality null == undefined
true (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
What does typeof null return? Why?
What does typeof undeclaredVar return for a name that was never declared at all?
What does typeof [] return? Why is the answer arguably wrong?
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
=== strict
Object.is
Coerces types?
yes - many rules
no
no
0 == false
true
false
false
'1' == 1
true
false
false
null == undefined
true
false
false
NaN === NaN
false
false
true
+0 === -0
true
true
false
Recommended
never in new code
use this 99% of the time
when 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 REPLconsole.log(0 == false); // true - boolean -> number, 0 == 0console.log('' == 0); // true - string '' -> 0, 0 == 0console.log(null == undefined); // true - one of the special casesconsole.log([] == ![]); // true - !![] -> false -> 0; [] -> '' -> 0console.log(NaN === NaN); // false - NaN is never equal to anything, even itselfconsole.log(Object.is(NaN, NaN)); // true - Object.is exists for this case
[] == ![] 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
Predict: '' == false, '0' == false, '' == '0'.
Predict: 1 == [1] - true or false? Trace the coercion.
Why does Playwright's expect use deep-equality for objects rather than ===?
Write a helper looseEquals(a, b) in 5 lines that wraps == but logs the coercion path to console.
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
Operator
Equivalent
Example
=
bind
let 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 truthy
x &&= 'go';
||=
assign if left is falsy
x ||= 'default';
??=
assign if left is nullish
x ??= 'fallback';
Verbose vs shorthand - same effect, half the typing
assign-ops.js
// arithmetic shorthandletcount = 10;
count += 5; // 15count *= 2; // 30count %= 7; // 2count **= 3; // 8// logical-assigned (ES2021)letconfig = { retries: 0 };
config.retries ||= 3; // 3 - because 0 is falsyconfig.retries ??= 5; // stays 3 - because 3 is not nullishconfig.debug ??= false; // false - because undefined is nullishconsole.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
Predict what config.retries ends up as in the snippet above. Verify by running.
Rewrite a long-winded if (!x) x = 'default' using both ||= and ??=. When do the two differ?
What does let s = 'abc'; s += 1; evaluate to? Why?
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.
Operator
Read as
Coerces?
Example
<
less than
yes
2 < 3 -> true
<=
less or equal
yes
3 <= 3 -> true
>
greater than
yes
'b' > 'a' -> true
>=
greater or equal
yes
'5' >= 5 -> true
!=
loose not equal
yes
1 != '1' -> false
!==
strict not equal
no
1 !== '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 mixedconsole.log(5 < 10); // true - both numbersconsole.log('5' < '10'); // false - '1' < '5' as STRINGSconsole.log('5' < 10); // true - mixed, '5' -> 5, then 5 < 10console.log('a' < 'b'); // true - codepoint compareconsole.log('A' < 'a'); // true - 'A' = 65, 'a' = 97console.log(NaN < 0); // false - NaN compares false to everythingconsole.log(NaN > 0); // false - including to 0 and to itself
String compare - lex order means '5' > '10' because '5' > '1'
Drill 10.1
Predict '2' < '10' < '100' step by step (recall comparisons are left-associative).
Sort the array ['10', '2', '1'] with Array#sort and observe the surprising result. Fix it with a custom comparator.
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
Expression
Result
Why
true && 'yes'
'yes'
left truthy -> return right
false && 'yes'
false
left 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 semantics - && stops at first falsy, || stops at first truthy
logical-ops.js
// short-circuit returns the operand, not a booleanconstuser = { name: 'pia' };
// guard + accessconstletter = user && user.name[0]; // 'p'// fallbackconstlabel = user.role || 'guest'; // 'guest'// to-boolean coercionconsthasName = !!user.name; // true// short-circuit side effectuser && console.log('user is present'); // firesnull && console.log('never fires'); // skipped
The 8 falsy values
Every other value is truthy. Memorise this list - it powers ||, !, and if:
Predict: '' || 0 || null || 'last' - what value comes out?
Predict: true && 'a' && 0 && 'c' - what value comes out?
Refactor if (user) { user.save(); } into a one-liner using &&.
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 - concatenationconstname = 'tta';
constbatch = 2026;
consta = 'hello ' + name + ' batch ' + batch;
// 'hello tta batch 2026' - + coerces number to string when one side is string// template literal - interpolation, multi-line, no escapesconstb = `hello ${name} batch ${batch}`;
// same string, cleaner// multi-lineconsttpl = `
<p>Welcome ${name}!</p>
<p>Batch ${batch}.</p>
`;
// pitfall: + with mixed typesconsole.log('2' + 3); // '23' - number coerced to stringconsole.log(2 + '3'); // '23' - sameconsole.log(2 + 3 + '4'); // '54' - left to right, 2+3=5, then 5+'4'
+ 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
Predict: '' + 1 + 2 vs 1 + 2 + '' - same or different?
Convert a four-part concatenation 'Rs ' + base + ' + ' + tax + ' = ' + total into a template literal.
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 - love it for one decision, avoid for chained side effects
Drill 13.1
Refactor a 6-line if/else if/else ladder for a 4-grade scoring rubric into a single nested ternary.
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.
Operator
Returns
What it tests
Example
typeof x
string
primitive type or 'object' / 'function'
typeof 5 -> 'number'
x instanceof C
boolean
is C.prototype in x's chain
[] instanceof Array
k in obj
boolean
does obj have key k (including inherited)
'sku' in product
delete obj.k
boolean
removes the property, returns success
delete cart.coupon
void expr
undefined
evaluates expr, discards result
void 0 -> undefined
The typeof grid - all nine results
Nine possible typeof results - memorise the order, especially null and array
type-ops.js
// typeof - safe even on undeclared namesconsole.log(typeofundeclared); // 'undefined' - no error// instanceof - prototype chain walkconsole.log([] instanceofArray); // trueconsole.log([] instanceofObject); // true - Array extends Object// in - includes inherited keysconstcart = { sku: 'tta-001', qty: 2 };
console.log('sku'incart); // trueconsole.log('toString'incart); // true - inherited from Object.prototype// delete - removes own propertydeletecart.qty;
console.log(cart); // { sku: 'tta-001' }// void - force undefined resultconsole.log(void0); // undefinedconsole.log(voidrunForSideEffect?.()); // undefined regardless of return
Drill 14.1
Predict typeof for: NaN, Infinity, null, an arrow function, an array.
Distinguish: when do you use typeof x === 'undefined' vs x === undefined?
Why is Array.isArray(x) preferred over x instanceof Array in browser code that talks to iframes?
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
constcart = { items: [{ sku: 'tta-001', price: 199 }] };
constempty = {};
// before ?. (old style)consta = cart && cart.items && cart.items[0] && cart.items[0].price;
// 199// after ?. - same intent, far cleanerconstb = cart?.items?.[0]?.price; // 199constc = empty?.items?.[0]?.price; // undefined - no error// optional method callconstd = cart.checkout?.(); // undefined if checkout is not a function// optional via dynamic keyconstkey = 'items';
conste = 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"constretries1 = 0 || 3; // 3 - 0 was falsyconstlabel1 = '' || 'guest'; // 'guest'// ?? respects 0 and ''constretries2 = 0 ?? 3; // 0 - 0 is not nullishconstlabel2 = '' ?? 'guest'; // '' - '' is not nullishconstcfg = { timeout: 0, tag: null };
consttimeout = cfg.timeout ?? 30; // 0 - intentional zero, keptconsttag = cfg.tag ?? 'untagged'; // 'untagged' - null is nullish
?? 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 missingconstprice = 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
Replace this snippet with one that uses ?. + ??: const n = obj && obj.user && obj.user.name ? obj.user.name : 'anon';
Predict: 0 ?? 'def' vs 0 || 'def' - which returns what?
What does cart?.items?.length === 0 evaluate to when cart is undefined?
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.
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 expressionconstx = (1, 2, 3); // 3// for-loop with two countersfor (leti = 0, j = 10; i < j; i++, j--) {
// runs while i < j; both counters tick each pass
}
Drill 16.1
Use spread to clone an array; mutate the clone; verify the original is untouched.
Predict: Math.max(...[3, NaN, 5]) - what does it return?
What does (2, 3, 4) ** 2 evaluate to? Trace the steps.
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.
'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.
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.
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.