Frontend Engineering

JavaScript from the Inside Out: A Gentle Guide to Scope, Closures, this, Prototypes and Async

Afzal AhmedFaz Ahmed
·28 July 2026·65 min read
JavaScriptECMAScriptClosuresPrototypesPromisesAsync/AwaitES ModulesTypeScriptReactAngularNode.js

Why This Matters

A slow, beginner-friendly and code-heavy guide to JavaScript's core mechanisms: values, types, variables, functions, scope, hoisting, closure, this, prototypes, classes, coercion, the event loop, Promises, iterators, modules, performance and framework connections.

These are my slow, practical revision notes for understanding JavaScript as a language rather than memorising isolated tricks. The ideas are inspired by the subjects made famous by Kyle Simpson's *You Don't Know JS* series, but the explanations, examples, structure and opinions here are original. Read one section at a time, run every example, and predict the output before looking at it.
JavaScript often feels easy during the first week and mysterious during the sixth month. The syntax is welcoming. We can put a value in a variable, write a function and respond to a button click with very little ceremony. The confusion arrives later: a variable appears to survive after a function returns; this changes when a method is passed as a callback; an object seems to inherit a method it does not contain; a timer with zero milliseconds runs after other code; and TypeScript-approved data fails at runtime.

None of this means JavaScript is random. It means we need a better model.

This guide builds that model from the ground up. We will study values and types, variables and scope, hoisting and the temporal dead zone, closure, functions, this, objects, prototypes, classes, coercion, equality, asynchronous work, the event loop, Promises, generators, iterators, modules and modern syntax. The running examples use a small learning application called StudyDesk. It has learners, lessons, progress counters and data loaded from an API. The domain is intentionally simple so we can concentrate on the language.


1. What JavaScript actually is

JavaScript is a programming language standardised under the name ECMAScript. ECMAScript defines the language: values, operators, objects, functions, modules, Promises and the rules for evaluating code. A host supplies the surrounding environment. A browser supplies the DOM, fetch, timers and events. Node.js supplies filesystem, process, networking and server APIs. setTimeout is therefore a host API, not a core JavaScript language feature.

That distinction explains why the same language can run in a browser, a server, a command-line tool or an embedded device.

// JavaScript language features
const lessons = ["scope", "closure", "prototype"];
const upper = lessons.map(lesson => lesson.toUpperCase());

// Browser host feature
document.querySelector("#topics").textContent = upper.join(", ");

// Node.js host feature
// const text = await fs.readFile("notes.txt", "utf8");

The array, string, function and map behavior belong to JavaScript. document belongs to the browser. A beginner benefits from asking, “Is this the language or the host?” Documentation and portability become much easier after that.

JavaScript is dynamically typed. A variable does not permanently own one type; a value has a type, and the variable is a binding that can refer to a value.

let current = 42;       // current refers to a number
current = "forty-two"; // now it refers to a string

This flexibility is not the same as having no types. The operations JavaScript performs depend heavily on value types. Dynamic typing merely means type checking happens mainly while the program runs.

JavaScript code is parsed before it executes. Engines create representations of scopes and declarations, compile or interpret instructions, optimise frequently executed paths and de-optimise when assumptions stop being true. We do not need engine-internal expertise to write an application, but we should discard the misleading idea that the engine blindly reads one line at a time without preparation.

A three-question habit

Whenever code surprises me, I ask:

  1. What values and types are involved?
  2. In which scope is each identifier resolved?
  3. What is the exact call or scheduling mechanism?
Those three questions solve a remarkable number of JavaScript puzzles.

Beginner definition: JavaScript is a dynamically typed ECMAScript language that runs inside a host such as a browser or Node.js. The language defines how code and values behave; the host provides environment-specific capabilities.


2. Values, types and variables

A value is a piece of data: 7, "hello", true, null, an object or a function. A type is a category of values with particular behavior. A variable is a named binding through which our code can access a value.

Modern JavaScript has eight language types. Seven are primitive types:

  • undefined
  • null
  • boolean
  • number
  • bigint
  • string
  • symbol
The eighth is object. Arrays, dates, regular expressions and ordinary objects are objects. Functions are callable objects; typeof reports them with the useful special result "function".
console.log(typeof undefined);    // "undefined"
console.log(typeof true);         // "boolean"
console.log(typeof 42);           // "number"
console.log(typeof 42n);          // "bigint"
console.log(typeof "lesson");    // "string"
console.log(typeof Symbol("id")); // "symbol"
console.log(typeof {});           // "object"
console.log(typeof []);           // "object"
console.log(typeof function () {}); // "function"
console.log(typeof null);         // "object" — a historical language quirk

typeof null is a famous trap. It does not mean null is really an object. The language type of null is Null. Test it directly:

function describe(value) {
  if (value === null) return "deliberately empty";
  if (Array.isArray(value)) return "an array";
  return typeof value;
}

Primitive values and objects

Primitive values are not objects and do not carry mutable properties. Objects are collections of properties and can be changed.

let title = "Scope";
title.level = "beginner";
console.log(title.level); // undefined

const lesson = { title: "Scope" };
lesson.level = "beginner";
console.log(lesson.level); // "beginner"

Strings appear to have methods because JavaScript temporarily makes the relevant String prototype behavior available. We can write title.toUpperCase() without the primitive string becoming a permanently mutable object.

Copying primitives and sharing object references

let scoreA = 10;
let scoreB = scoreA;
scoreB = 20;
console.log(scoreA); // 10

const learnerA = { score: 10 };
const learnerB = learnerA;
learnerB.score = 20;
console.log(learnerA.score); // 20

The first assignment copies a primitive value. The second copies a reference to the same object. People sometimes say “objects are passed by reference,” but a more precise model is that JavaScript always passes values; for an object, that value is an object reference. Reassigning the parameter does not replace the caller's binding, while mutating the referenced object is visible to both.

function change(person) {
  person.name = "Amina";       // changes the shared object
  person = { name: "Different" }; // only reassigns the local parameter
}

const person = { name: "Faz" };
change(person);
console.log(person.name); // "Amina"

let, const and var

Use const when the binding will not be reassigned. Use let when reassignment is part of the meaning. Study var because existing code uses it and because it teaches function scope, but prefer block-scoped declarations in new application code.

const courseName = "JavaScript Foundations";
let completedLessons = 0;
completedLessons += 1;

const protects the binding, not the object:

const progress = { completed: 0 };
progress.completed += 1; // allowed
// progress = {};        // TypeError-causing assignment to a constant binding

If immutability is required, create new values or use carefully selected freezing tools. Object.freeze is shallow:

const course = Object.freeze({
  title: "JavaScript",
  settings: { theme: "dark" },
});

course.settings.theme = "light"; // nested object is not frozen

Beginner definition: A variable is a named binding, not a box with a permanent type. const means the binding cannot be reassigned; it does not mean an object can never change.


3. Expressions, statements and control flow

An expression produces a value. A statement performs an action or controls execution.

2 + 3                   // expression producing 5
completed >= total     // expression producing a boolean
getLearnerName()       // call expression producing the function's result

const total = 5;       // declaration statement
if (total > 0) {}      // if statement

Understanding the difference helps us read concise modern syntax. A conditional expression produces a value:

const label = completed === total ? "Complete" : "In progress";

Use it for a small choice between values, not a hidden multi-step workflow.

Conditions use truthiness

JavaScript converts the condition to a boolean. The falsy values are:

false
0 and -0
0n
"" (empty string)
null
undefined
NaN

Everything else is truthy, including "false", "0", empty arrays and empty objects.

if ([]) console.log("An empty array is truthy");
if ({}) console.log("An empty object is truthy");
if ("false") console.log("A non-empty string is truthy");

Do not use a truthiness check when zero or an empty string is valid data:

function showScore(score) {
  // Wrong if 0 is a real score:
  // return score ? `${score} points` : "No score";

  return score == null ? "No score" : `${score} points`;
}

Here score == null is one deliberate, documented use of loose equality: it matches both null and undefined, and no other values. A team may prefer score === null || score === undefined for explicitness. The important skill is understanding the choice.

Loops and iteration

const lessons = ["scope", "closure", "prototype"];

for (const lesson of lessons) {
  console.log(lesson); // values
}

for (const index in lessons) {
  console.log(index); // enumerable property keys: "0", "1", "2"
}

Use for...of for iterable values. for...in enumerates property keys and is normally used carefully with objects, often combined with an own-property check.

const ratings = { scope: 5, closure: 4 };

for (const key in ratings) {
  if (Object.hasOwn(ratings, key)) {
    console.log(key, ratings[key]);
  }
}

For transformations, array methods often state intent more directly:

const completedTitles = lessons
  .filter(lesson => lesson.completed)
  .map(lesson => lesson.title);

Do not force every loop into map. Use map when producing one output element per input element, filter when selecting, reduce when intentionally combining, and a loop when a loop is clearer.


4. Functions are values

A function packages behavior. In JavaScript, functions are first-class values: they can be stored, passed, returned and placed in objects.

function greet(name) {
  return `Hello, ${name}`;
}

const greeter = greet;
const operations = [greet];

function run(operation, value) {
  return operation(value);
}

console.log(run(greeter, "Faz"));

A parameter is a binding named in the function definition. An argument is the value supplied by a caller.

function multiply(value, factor) { // parameters
  return value * factor;
}

multiply(6, 7); // arguments

Missing arguments become undefined; extra arguments are permitted. Default parameters make intended fallback behavior visible:

function createLesson(title, level = "beginner") {
  return { title, level };
}

The default applies when the argument is missing or explicitly undefined, but not when it is null.

Declarations and expressions

// Function declaration
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// Function expression
const formatTotal = function formatTotal(total) {
  return `£${total.toFixed(2)}`;
};

// Arrow function
const double = value => value * 2;

A named function expression gives useful stack traces and allows self-reference. Arrow functions are compact and capture this lexically; that makes them excellent callbacks but not universal replacements for ordinary functions.

Pure functions and side effects

A pure function returns the same result for the same inputs and does not change observable outside state.

function percentage(completed, total) {
  return total === 0 ? 0 : Math.round((completed / total) * 100);
}

Logging, network requests, DOM updates, timers and mutations are side effects. Side effects are necessary; the goal is to place them deliberately at system boundaries.

function progressMessage(progress) {
  return `${progress.completed} of ${progress.total} complete`;
}

function renderProgress(element, progress) {
  element.textContent = progressMessage(progress); // DOM side effect at boundary
}

Rest parameters and spread

Rest gathers remaining arguments into a real array:

function average(...values) {
  if (values.length === 0) return 0;
  return values.reduce((sum, value) => sum + value, 0) / values.length;
}

average(4, 5, 3);

Spread expands an iterable or copies enumerable object properties:

const core = ["scope", "closure"];
const topics = [...core, "prototypes"];

const learner = { name: "Faz", level: 1 };
const promoted = { ...learner, level: 2 };

Both copies are shallow. Nested objects remain shared.

Beginner definition: A function is a callable object and a first-class value. A callback is simply a function supplied to other code so that code can call it later or as part of an operation.


5. Lexical scope: where names can be found

Scope is the set of rules controlling where an identifier is visible and how it is resolved. JavaScript primarily uses lexical scope: availability is determined by where code is written in the source.

const school = "StudyDesk";

function createLearner(name) {
  const level = "beginner";

  function describe() {
    const message = `${name} is a ${level} at ${school}`;
    return message;
  }

  return describe();
}

When describe reads name, the engine looks in its local scope. It does not find name, so it continues into the enclosing createLearner scope. school is found farther out. message is local to describe and cannot be read from createLearner.

Think of nested scopes as one-way visibility. Inner code can look outward. Outer code cannot reach inward.

Shadowing

An inner declaration can use the same name and shadow the outer binding:

const level = "global";

function showLevel() {
  const level = "function";

  if (true) {
    const level = "block";
    console.log(level); // "block"
  }

  console.log(level); // "function"
}

showLevel();
console.log(level); // "global"

Shadowing is not automatically wrong, but repeated generic names can make code harder to follow.

Function scope and block scope

var is function-scoped. let and const are block-scoped.

function example() {
  if (true) {
    var oldStyle = "visible throughout the function";
    let modern = "visible only in this block";
  }

  console.log(oldStyle);
  // console.log(modern); // ReferenceError
}

Blocks include if, for, while, switch, try/catch and standalone {} blocks. Narrow scope reduces the number of places where a value can be accidentally used.

Modules add module scope

// progress.js
const privateScale = 100;

export function percentage(done, total) {
  return total ? Math.round((done / total) * privateScale) : 0;
}

privateScale is not global. It is available inside this module but is not exported. Modules also execute in strict mode and have their own this behavior.

Scope is not the same as an object's properties

const learner = { name: "Faz" };
console.log(learner.name); // property access
console.log(learner);      // identifier lookup

Finding learner uses scope rules. Finding name on that object uses property lookup and possibly the prototype chain. Keeping those mechanisms separate prevents confusion later.

Beginner definition: Lexical scope means the written nesting of declarations and functions determines where names can be used. Identifier lookup starts locally and moves outward through enclosing scopes.


6. Hoisting and the temporal dead zone

“JavaScript moves declarations to the top” is a teaching shortcut, not a literal description. A better model is that scope bindings are created during setup before statements execute, but different declarations are initialised differently.

var

console.log(topic); // undefined
var topic = "closure";
console.log(topic); // "closure"

The binding exists from the beginning of its function or global scope and is initially undefined. The assignment happens where written.

let and const

// console.log(topic); // ReferenceError
const topic = "closure";

The binding exists for the whole block, but it cannot be accessed before the declaration is evaluated. The region from the start of the scope until initialisation is called the temporal dead zone (TDZ). It helps expose use-before-initialisation mistakes instead of quietly returning undefined.

Even typeof, normally safe for undeclared names, throws within a TDZ:

console.log(typeof completelyMissing); // "undefined"
// console.log(typeof waiting);         // ReferenceError
let waiting = true;

Function declarations

console.log(makeLabel("scope")); // "Topic: scope"

function makeLabel(topic) {
  return `Topic: ${topic}`;
}

Function declarations are initialised with their function value during scope setup, so calling them earlier in the scope works. A function expression follows the rules of its variable declaration:

// format("scope"); // ReferenceError because format is in the TDZ
const format = topic => topic.toUpperCase();

A loop bug that teaches scope

for (var index = 0; index < 3; index += 1) {
  setTimeout(() => console.log(index), 0);
}
// Later: 3, 3, 3

There is one function-scoped index binding. By the time callbacks run, the loop has completed and its value is 3.

for (let index = 0; index < 3; index += 1) {
  setTimeout(() => console.log(index), 0);
}
// Later: 0, 1, 2

For a for loop, let provides a distinct binding for each iteration. Each callback closes over its own binding.

Hoisting should not be used as a party trick. Declare values close to their use, prefer clear ordering and understand the mechanism so legacy code and errors are predictable.

Beginner definition: Hoisting describes the observable result of bindings being created during scope setup before execution. var starts as undefined; let and const remain inaccessible in the temporal dead zone; function declarations are available with their function value.


7. Closure: a function keeps access to its lexical environment

A closure is a function together with its continuing access to the lexical environment where that function was created. JavaScript creates closures whenever functions are created, although closure becomes most visible when a function is used outside the place where it was defined.

function makeCounter() {
  let count = 0;

  return function increment() {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Step slowly through it:

  1. Calling makeCounter creates a new local count binding.
  2. It creates and returns the increment function.
  3. makeCounter finishes, but increment is still reachable through counter.
  4. increment was created where count was in scope.
  5. Calling it later still resolves count from that preserved lexical environment.
The function does not store a frozen copy of count. It retains access to the binding. That is why updates are visible on later calls.

Separate calls create separate closures

const lessonsCounter = makeCounter();
const quizCounter = makeCounter();

console.log(lessonsCounter()); // 1
console.log(lessonsCounter()); // 2
console.log(quizCounter());    // 1

Each call to makeCounter creates a fresh environment and fresh count binding.

Function factories

function minimumScore(required) {
  return function passed(actual) {
    return actual >= required;
  };
}

const passedBeginner = minimumScore(50);
const passedAdvanced = minimumScore(80);

console.log(passedBeginner(65)); // true
console.log(passedAdvanced(65)); // false

required is configured once and remains available. This technique is useful for validators, formatters, event handlers and dependency configuration.

Private state through closure

function createProgressTracker(totalLessons) {
  let completed = 0;

  function assertValid() {
    if (completed > totalLessons) {
      throw new Error("Completed lessons cannot exceed the total");
    }
  }

  return Object.freeze({
    completeLesson() {
      completed += 1;
      assertValid();
    },
    snapshot() {
      return { completed, totalLessons };
    },
  });
}

const tracker = createProgressTracker(3);
tracker.completeLesson();
console.log(tracker.snapshot()); // { completed: 1, totalLessons: 3 }

Outside code cannot directly assign completed. The returned methods share access to the same enclosed environment.

Closure in event handlers

function wireLessonButton(button, lessonId) {
  button.addEventListener("click", () => {
    console.log(`Opening lesson ${lessonId}`);
  });
}

The callback may run minutes later, but it still has access to lessonId. Framework event handlers, React callbacks, RxJS operators and Node route handlers depend constantly on closure.

Closure and stale values

Closure preserves access to the particular bindings from a render or call. In React, every render creates a new snapshot and new closures:

function DelayedAlert() {
  const [count, setCount] = useState(0);

  function showLater() {
    setTimeout(() => alert(count), 3000);
  }

  return (
    <>
      <button onClick={() => setCount(value => value + 1)}>{count}</button>
      <button onClick={showLater}>Show this render's count later</button>
    </>
  );
}

The delayed callback sees the count from the render in which showLater was invoked. This is not closure failing; it is closure working exactly as defined. Functional state updates or refs solve different requirements.

Memory considerations

A reachable closure can keep its referenced environment reachable. Do not retain large data accidentally in long-lived callbacks, caches or subscriptions. Remove event listeners and cancel subscriptions when their lifetime ends.

function mount(element, hugeModel) {
  const handleClick = () => console.log(hugeModel.title);
  element.addEventListener("click", handleClick);

  return function unmount() {
    element.removeEventListener("click", handleClick);
  };
}

Beginner definition: Closure is the ability of a function to keep accessing bindings from the lexical scope where it was created, even when the function runs somewhere else or at a later time.


8. this: determined by invocation

this is a special value available inside ordinary functions. For most ordinary functions, its value is determined by how the function is called, not by where the function was written. This is the call-site model.

Method invocation

const learner = {
  name: "Faz",
  introduce() {
    return `I am ${this.name}`;
  },
};

console.log(learner.introduce()); // "I am Faz"

The call has a receiver before the dot: learner. Therefore this is learner for that invocation.

Losing the receiver

const introduce = learner.introduce;
introduce();

It is the same function but a different call. In a modern module or strict mode, this is undefined, so reading this.name throws. Passing a method as a callback commonly loses its receiver.

setTimeout(learner.introduce, 0); // receiver is not preserved
setTimeout(() => console.log(learner.introduce()), 0); // explicit method call

Explicit binding with call, apply and bind

function introduce(punctuation) {
  return `I am ${this.name}${punctuation}`;
}

const context = { name: "Amina" };

introduce.call(context, "!");       // invokes now, arguments individually
introduce.apply(context, ["!"]);    // invokes now, arguments in an array
const bound = introduce.bind(context); // returns a new bound function
bound("!");

bind is useful when an API needs a callback but we require a stable receiver. Avoid binding repeatedly during removal of event listeners because each bind call creates a new function.

Constructor invocation with new

function Learner(name) {
  this.name = name;
}

Learner.prototype.introduce = function () {
  return `I am ${this.name}`;
};

const faz = new Learner("Faz");

Simplified, new creates an object linked to Learner.prototype, calls Learner with that object as this, and returns the object unless the function explicitly returns another object.

Arrow functions have lexical this

An arrow function does not create its own this. It uses this from its surrounding lexical context.

const timer = {
  seconds: 0,
  start() {
    setInterval(() => {
      this.seconds += 1; // this comes from start's method invocation
    }, 1000);
  },
};

This makes arrows useful inside methods. It makes them unsuitable as object methods when we expect a dynamic receiver:

const broken = {
  name: "Faz",
  introduce: () => `I am ${this.name}`,
};

The arrow does not receive broken as this merely because it is stored on that object.

Classes and this

Class methods follow ordinary method-call rules. JavaScript does not automatically bind them.

class LessonView {
  constructor(title) {
    this.title = title;
  }

  show() {
    console.log(this.title);
  }
}

const view = new LessonView("Closure");
const callback = view.show;
// callback(); // TypeError in strict class semantics

Use a wrapper, bind once, or use a class field arrow where that trade-off is appropriate.

this is not scope

this.name performs property lookup on the current receiver. Bare name performs lexical identifier lookup. this does not provide access to a function's outer variables.

Beginner definition: For an ordinary function, this is the receiver selected by the way the function is invoked. Arrow functions do not bind their own this; they capture it from the surrounding context.


9. Objects, properties and descriptors

An object is a dynamic collection of properties. Each property has a key and a descriptor. Most keys are strings; symbols can also be keys.

const lesson = {
  id: 7,
  title: "Objects",
  completed: false,
};

console.log(lesson.title);      // dot notation
console.log(lesson["title"]); // bracket notation

Dot notation uses a fixed valid identifier. Bracket notation evaluates an expression, so it supports dynamic keys and keys containing spaces.

const field = "completed";
console.log(lesson[field]);

const report = { "completion rate": 75 };
console.log(report["completion rate"]);

Own and inherited properties

An own property exists directly on the object. An inherited property is found through the object's prototype chain.

console.log(Object.hasOwn(lesson, "title"));    // true
console.log(Object.hasOwn(lesson, "toString")); // false
console.log("toString" in lesson);              // true, found through prototype

Use Object.hasOwn when the question is specifically whether the object owns a property. The in operator asks whether lookup can find it anywhere on the object or its prototype chain.

Property descriptors

Ordinary assignment creates writable, enumerable and configurable properties. Descriptors let us control those characteristics.

const account = {};

Object.defineProperty(account, "id", {
  value: "learner-42",
  writable: false,
  enumerable: true,
  configurable: false,
});

console.log(Object.getOwnPropertyDescriptor(account, "id"));
  • writable: may the data property's value be changed?
  • enumerable: does it appear in operations such as Object.keys?
  • configurable: may it be deleted or reconfigured?
Accessors use getter and setter functions instead of a stored data value:
const progress = {
  completed: 3,
  total: 4,
  get percentage() {
    return Math.round((this.completed / this.total) * 100);
  },
};

console.log(progress.percentage); // 75

A getter looks like property access to the caller. Keep it unsurprising and inexpensive; a network request hidden behind object.value would violate expectations.

Object destructuring

const learner = {
  id: "l1",
  profile: { displayName: "Faz" },
  role: "student",
};

const {
  id,
  profile: { displayName },
  role = "student",
} = learner;

Destructuring declares bindings from object properties. It does not deep-clone the object. Defaults apply only to undefined, not to null.

Symbols

A symbol is a unique primitive often used as a collision-resistant property key.

const internalId = Symbol("internalId");
const record = { [internalId]: 123, title: "Scope" };

console.log(record[internalId]); // 123
console.log(Object.keys(record)); // ["title"]

Symbols are not security or true privacy. Reflect.ownKeys(record) can discover symbol keys. Well-known symbols such as Symbol.iterator let objects participate in language protocols.

Beginner definition: An object stores keyed properties. Property lookup first checks the object's own properties and can then continue through its prototype chain.


10. Prototypes: objects linked to objects

Every ordinary JavaScript object has an internal [[Prototype]] link whose value is another object or null. When a requested property is not an own property, JavaScript follows that link. This is the prototype chain.

const sharedLessonBehavior = {
  describe() {
    return `${this.title} takes ${this.minutes} minutes`;
  },
};

const lesson = Object.create(sharedLessonBehavior);
lesson.title = "Closures";
lesson.minutes = 25;

console.log(lesson.describe());

lesson does not own describe. Lookup fails on lesson, continues to sharedLessonBehavior, finds the function and invokes it with lesson as the receiver because the call is lesson.describe().

console.log(Object.hasOwn(lesson, "describe")); // false
console.log(Object.getPrototypeOf(lesson) === sharedLessonBehavior); // true

This is delegation: lesson delegates missing behavior lookup to another object.

The two meanings of “prototype”

This terminology confuses beginners because two related ideas use the same word:

  1. An object's internal [[Prototype]] link.
  2. The ordinary .prototype property found on constructable functions.
function Lesson(title) {
  this.title = title;
}

Lesson.prototype.describe = function () {
  return this.title;
};

const scope = new Lesson("Scope");

console.log(Object.getPrototypeOf(scope) === Lesson.prototype); // true

Lesson.prototype is an ordinary object created for the function. new Lesson(...) links the new object's internal prototype to that object.

Prototype lookup is live

The method is not copied into each instance:

Lesson.prototype.durationLabel = function () {
  return "Duration not set";
};

console.log(scope.durationLabel()); // existing object sees new delegated method

An own property shadows an inherited property:

scope.describe = function () {
  return `Custom: ${this.title}`;
};

console.log(scope.describe());
delete scope.describe;
console.log(scope.describe()); // inherited method becomes visible again

Arrays use prototypes too

const topics = ["scope", "closure"];

console.log(Object.getPrototypeOf(topics) === Array.prototype); // true
console.log(Array.prototype.hasOwnProperty("map"));             // true

topics.map works because lookup reaches Array.prototype. Modifying built-in prototypes is usually unsafe in application code: it can collide with future standards, libraries or other teams' assumptions.

Prototype pollution

Carelessly copying untrusted keys such as __proto__ into ordinary objects can change prototypes in vulnerable code paths. Validate keys, prefer safe libraries, keep dependencies patched and consider Object.create(null) for dictionaries that need no inherited behavior.

const dictionary = Object.create(null);
dictionary["toString"] = "a normal data key";
console.log(Object.getPrototypeOf(dictionary)); // null

Beginner definition: A prototype is an object that another object delegates property lookup to. A prototype chain is the sequence of those links until a property is found or the chain reaches null.


11. Classes: modern syntax over prototype mechanisms

JavaScript classes provide familiar syntax for constructing related objects and sharing methods. They do not replace prototypes; class methods are placed on prototype objects.

class Lesson {
  constructor(title, minutes) {
    this.title = title;
    this.minutes = minutes;
  }

  describe() {
    return `${this.title} takes ${this.minutes} minutes`;
  }
}

const closureLesson = new Lesson("Closure", 25);

console.log(closureLesson.describe());
console.log(Object.getPrototypeOf(closureLesson) === Lesson.prototype); // true

constructor initialises each instance. describe is shared through Lesson.prototype, not recreated as an own function for every object.

Inheritance

class VideoLesson extends Lesson {
  constructor(title, minutes, videoUrl) {
    super(title, minutes);
    this.videoUrl = videoUrl;
  }

  describe() {
    return `${super.describe()} and includes video`;
  }
}

extends links prototype chains for instance and static behavior. super(...) calls the parent constructor and must run before using this in a derived constructor.

Inheritance is useful when the relationship is genuinely substitutable: a VideoLesson is a specialised Lesson that respects its expected contract. Prefer composition when capabilities vary independently.

function createLesson({ title, player, progressStore }) {
  return {
    title,
    play() {
      player.play(title);
    },
    complete() {
      progressStore.markComplete(title);
    },
  };
}

Composition builds an object from collaborators instead of creating a deep family tree.

Private fields

class ProgressTracker {
  #completed = 0;

  complete() {
    this.#completed += 1;
  }

  get completed() {
    return this.#completed;
  }
}

#completed is language-enforced private state, not a naming convention. It can be accessed only within class bodies that declare it. Closure-based privacy and private fields are both valid; choose based on the shape and behavior of the abstraction.

Static members

class Lesson {
  static fromJson(json) {
    const data = JSON.parse(json);
    return new Lesson(data.title, data.minutes);
  }
}

Lesson.fromJson belongs to the class constructor object, not to each lesson instance. Static factory functions are useful when construction needs a name or conversion step.

TypeScript does not change runtime inheritance

class Lesson {
  constructor(
    public readonly title: string,
    public readonly minutes: number,
  ) {}
}

TypeScript checks the types during development and emits JavaScript. At runtime the object still uses JavaScript's prototype and property mechanisms.

Beginner definition: A JavaScript class is syntax for creating objects, initialising instance properties and sharing methods through prototypes. It looks class-oriented while still running on JavaScript's object model.


12. Coercion: converting values between types

Coercion is conversion from one type to another. Explicit coercion names the conversion; implicit coercion occurs as part of another operation.

const formValue = "42";
const explicit = Number(formValue); // 42
const implicit = formValue * 1;      // 42

Prefer explicit conversion at application boundaries because it communicates intent and gives us a clear place to validate.

Strings from forms

Browser form values are strings even when an input uses type="number".

const input = document.querySelector("#minutes");
const minutes = Number(input.value);

if (!Number.isFinite(minutes) || minutes < 0) {
  throw new Error("Minutes must be a non-negative number");
}

Conversion and validation are separate. Number("banana") returns NaN, a number value representing an invalid numeric result.

console.log(typeof NaN);          // "number"
console.log(NaN === NaN);         // false
console.log(Number.isNaN(NaN));   // true

Use Number.isNaN, not comparison with NaN.

The + operator

Binary + performs numeric addition or string concatenation. If either primitive operand becomes a string, concatenation occurs.

console.log(2 + 3);       // 5
console.log("2" + 3);    // "23"
console.log(2 + "3");    // "23"
console.log(1 + 2 + "3"); // "33"
console.log("1" + 2 + 3); // "123"

Evaluation proceeds left to right. Parentheses and explicit conversions make business code safer:

const total = Number(priceText) + Number(taxText);

Boolean conversion

Boolean(value) follows the truthy/falsy rules. Double negation performs the same conversion but is less verbal.

Boolean("");       // false
Boolean("false");  // true
Boolean([]);       // true
Boolean(0);        // false

|| versus ??

Logical OR returns its right side when the left side is falsy. Nullish coalescing returns the right side only when the left side is null or undefined.

const savedVolume = 0;

console.log(savedVolume || 50); // 50 — loses valid zero
console.log(savedVolume ?? 50); // 0

Use ?? for missing-value defaults when 0, false or "" are valid.

Equality

Strict equality === compares without type coercion. Loose equality == follows a defined coercion algorithm.

0 === "0";       // false
0 == "0";        // true
false == 0;       // true
null == undefined; // true

For most application comparisons, strict equality is the clearest default. Learning loose equality remains valuable because legacy code, library internals and interview questions use it. Do not memorise a random chart; identify operand types, follow conversions and choose explicit code in production.

Object.is differs in two edge cases:

Object.is(NaN, NaN); // true
Object.is(0, -0);    // false
NaN === NaN;         // false
0 === -0;            // true

Object-to-primitive conversion

When an operation needs a primitive, objects may be converted using language hooks such as Symbol.toPrimitive, valueOf and toString.

const duration = {
  minutes: 30,
  [Symbol.toPrimitive](hint) {
    return hint === "string" ? `${this.minutes} minutes` : this.minutes;
  },
};

console.log(Number(duration)); // 30
console.log(String(duration)); // "30 minutes"

Custom coercion can be elegant in focused value objects but surprising in ordinary domain objects. Use it sparingly.

Beginner definition: Coercion is a type conversion. Explicit coercion states the requested conversion, such as Number(text). Implicit coercion is triggered by an operator or comparison.


13. Grammar details that prevent real bugs

Grammar is not decorative punctuation. It determines how source text is grouped and evaluated.

Operator precedence and grouping

const result = 2 + 3 * 4;   // 14
const grouped = (2 + 3) * 4; // 20

Use parentheses when they make intent easier to verify, even if precedence rules already give the desired result.

Short-circuit evaluation

&&, || and ?? return operand values rather than forced booleans, and may skip evaluation of the right operand.

const learner = session && session.learner;
const label = customLabel || "Default";
const count = storedCount ?? 0;

Modern optional chaining is clearer for safe property access:

const city = learner?.profile?.address?.city;
const result = plugin?.run?.();

Optional chaining stops only when the value immediately before ?. is null or undefined. It should not hide a property that is required by the domain.

Automatic semicolon insertion

JavaScript can insert semicolons in specific situations. Do not assume it simply adds one at every newline.

function createProgress() {
  return
  {
    completed: 0
  };
}

console.log(createProgress()); // undefined

A line terminator after return causes semicolon insertion. Put the expression on the same line:

function createProgress() {
  return {
    completed: 0,
  };
}

Whether a team writes semicolons or relies on a formatter, understand the restricted productions involving return, throw, break, continue, postfix ++ and --.

switch uses strict equality

switch (status) {
  case "new":
    startLesson();
    break;
  case "complete":
    showCertificate();
    break;
  default:
    showUnknownStatus();
}

Without break or another abrupt completion, execution falls through to the next case. Deliberate fall-through should be obvious and commented.

try, catch and finally

async function loadLesson(id) {
  const controller = new AbortController();
  try {
    return await fetchLesson(id, controller.signal);
  } catch (error) {
    throw new Error(`Could not load lesson ${id}`, { cause: error });
  } finally {
    stopLoadingIndicator();
  }
}

finally runs whether the try completes normally or abruptly. Avoid returning from finally; it can override an earlier return or thrown error.


14. Asynchronous JavaScript and the event loop

Synchronous code runs to completion on the current call stack. Asynchronous APIs arrange for work or continuation to happen later. JavaScript's event-loop model coordinates queued work while preserving run-to-completion for each job.

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

console.log("C");

Output:

A
C
B

Zero milliseconds means “eligible after at least this delay,” not “interrupt current code.” The current script finishes before the timer callback becomes a later task.

Call stack, host and queues

A gentle operational model is:

  1. JavaScript runs the current job on the call stack.
  2. A host API handles a timer, network request or event outside that stack.
  3. When ready, its continuation is queued.
  4. The event loop selects queued work when the current stack is empty.
  5. Promise reactions use a job/microtask queue that is drained before the next ordinary task.
console.log("script start");

setTimeout(() => console.log("timer"), 0);

Promise.resolve().then(() => console.log("promise"));

queueMicrotask(() => console.log("microtask"));

console.log("script end");

Typical browser and Node output:

script start
script end
promise
microtask
timer

The two microtasks are queued in order before the timer task runs. Hosts have detailed scheduling differences, especially Node's phases and special queues, so use this as a foundation rather than a complete host specification.

Async does not automatically mean parallel

While waiting for a network response, JavaScript can run other work. That concurrency does not mean two pieces of ordinary JavaScript are executing simultaneously on the same agent. Web Workers and Node worker threads provide separate execution agents for CPU-intensive parallelism.

// CPU-heavy work blocks interaction if run on the browser's main thread.
let total = 0;
for (let i = 0; i < 2_000_000_000; i += 1) {
  total += i;
}

Use a worker, server processing, chunking or a better algorithm depending on the task. async does not move this loop to another thread.

Callback problems

Callbacks are not bad. They are fundamental. Problems appear when coordination, error flow and ownership become difficult.

loadUser(userId, (userError, user) => {
  if (userError) return handle(userError);

  loadCourses(user.id, (courseError, courses) => {
    if (courseError) return handle(courseError);

    saveDashboard(courses, saveError => {
      if (saveError) return handle(saveError);
      showReady();
    });
  });
});

Indentation is merely a symptom. The deeper questions are: Will the callback run exactly once? Will it run synchronously or later? How are errors reported? Can it be cancelled? Who owns continuation of the workflow?

Beginner definition: The event loop is the host-coordinated mechanism that runs queued JavaScript jobs when the current job finishes. Asynchronous code schedules continuation; it does not interrupt the currently executing statement.


15. Promises and async/await

A Promise is an object representing the eventual completion or failure of an asynchronous operation. It begins pending and becomes fulfilled with a value or rejected with a reason. Once settled, it cannot change state again.

const promise = fetch("/api/lessons");

promise.then(
  response => console.log("fulfilled", response.status),
  error => console.error("rejected", error),
);

then returns a new Promise, which enables composition:

fetch("/api/lessons")
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return response.json();
  })
  .then(lessons => lessons.filter(lesson => lesson.published))
  .then(renderLessons)
  .catch(showError)
  .finally(hideSpinner);

A returned plain value fulfils the next Promise. A returned Promise is adopted. A thrown error rejects the next Promise. catch(handler) is essentially rejection handling for the chain.

fetch has two kinds of failure

fetch rejects for network-level failure or abort. An HTTP 404 or 500 normally fulfils with a Response, so check response.ok.

async function fetchJson(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`Request failed with ${response.status}`);
  }
  return response.json();
}

async functions

An async function always returns a Promise.

async function answer() {
  return 42;
}

answer().then(console.log); // 42

await pauses that async function's continuation until the awaited value settles. It does not block the whole JavaScript agent.

async function loadDashboard() {
  try {
    const learner = await fetchJson("/api/me");
    const lessons = await fetchJson(`/api/learners/${learner.id}/lessons`);
    return { learner, lessons };
  } catch (error) {
    console.error("Dashboard failed", error);
    throw error;
  }
}

The second request depends on the learner ID, so sequential awaiting is reasonable.

Avoid accidental waterfalls

// Independent operations run sequentially:
const profile = await fetchJson("/api/profile");
const catalogue = await fetchJson("/api/catalogue");

// Start both, then await both:
const [profile, catalogue] = await Promise.all([
  fetchJson("/api/profile"),
  fetchJson("/api/catalogue"),
]);

Promise.all rejects when one input rejects. Other operations are not automatically cancelled. Use AbortController when cancellation is required.

const controller = new AbortController();

try {
  const data = await fetchJson("/api/search?q=scope", {
    signal: controller.signal,
  });
} catch (error) {
  if (error instanceof DOMException && error.name === "AbortError") {
    console.log("Search cancelled");
  } else {
    throw error;
  }
}

controller.abort();

Promise combinators

  • Promise.all: all must fulfil; preserves input order.
  • Promise.allSettled: waits for every outcome and reports statuses.
  • Promise.race: settles with the first settled input.
  • Promise.any: fulfils with the first fulfilled input; rejects if all reject.
Choose based on the domain, not convenience.
const outcomes = await Promise.allSettled(
  lessonIds.map(id => saveProgress(id)),
);

const failed = outcomes.filter(outcome => outcome.status === "rejected");

Common Promise mistakes

Forgetting to return breaks a chain:

// Wrong: nested Promise is not connected to the outer chain.
loadUser().then(user => {
  loadLessons(user.id);
}).then(lessons => {
  console.log(lessons); // undefined
});

// Correct:
loadUser()
  .then(user => loadLessons(user.id))
  .then(lessons => console.log(lessons));

forEach does not await async callbacks:

// Starts work but does not wait as a group.
lessonIds.forEach(async id => {
  await saveProgress(id);
});

// Sequential:
for (const id of lessonIds) {
  await saveProgress(id);
}

// Concurrent:
await Promise.all(lessonIds.map(id => saveProgress(id)));

Unhandled rejections are bugs. Catch where recovery or reporting makes sense, and do not swallow errors with an empty catch.

Beginner definition: A Promise is a stable representation of a future outcome. async/await is syntax for composing Promise-based operations while retaining normal-looking control flow.


16. Iterators, iterables and generators

An iterator is an object with a next() method that returns objects shaped like { value, done }. An iterable provides an iterator through Symbol.iterator.

const topics = ["scope", "closure"];
const iterator = topics[Symbol.iterator]();

console.log(iterator.next()); // { value: "scope", done: false }
console.log(iterator.next()); // { value: "closure", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

for...of, spread, array destructuring and many built-ins consume iterable protocols.

A custom iterable

function lessonRange(start, end) {
  return {
    [Symbol.iterator]() {
      let current = start;
      return {
        next() {
          if (current <= end) {
            return { value: current++, done: false };
          }
          return { value: undefined, done: true };
        },
      };
    },
  };
}

console.log([...lessonRange(1, 4)]); // [1, 2, 3, 4]

Each call to Symbol.iterator creates new iteration state, so the range can be iterated repeatedly.

Generator functions

A generator function uses function* and can pause at yield. Calling it creates a generator iterator; the body does not run fully at once.

function* lessonNumbers() {
  yield 1;
  yield 2;
  return 3;
}

const numbers = lessonNumbers();
console.log(numbers.next()); // { value: 1, done: false }
console.log(numbers.next()); // { value: 2, done: false }
console.log(numbers.next()); // { value: 3, done: true }

for...of consumes yielded values but ignores the final returned value.

Generators express lazy sequences clearly:

function* take(items, limit) {
  let count = 0;
  for (const item of items) {
    if (count >= limit) return;
    yield item;
    count += 1;
  }
}

Async iterables

Async iterables provide values over time and are consumed with for await...of.

async function* pages(fetchPage) {
  let cursor = null;

  do {
    const page = await fetchPage(cursor);
    yield page.items;
    cursor = page.nextCursor;
  } while (cursor !== null);
}

for await (const lessons of pages(loadLessonPage)) {
  renderLessons(lessons);
}

This is a natural fit for paginated APIs, streams and progressive data sources.

Beginner definition: An iterable is a value that knows how to produce an iterator. A generator is a function whose execution can pause and resume, making it convenient for building lazy iterators.


17. Modern syntax with its mechanisms exposed

Modern JavaScript syntax is most useful when we understand what it changes and what it does not.

Template literals

const title = "Closure";
const minutes = 25;
const label = `${title} takes ${minutes} minutes`;

Expressions inside ${...} are evaluated and converted to strings. Tagged templates allow a function to process literal parts and values, useful in carefully designed SQL, CSS and localisation libraries.

Destructuring and renaming

const response = {
  data: { title: "Scope" },
  status: 200,
};

const { data: lessonData, status } = response;
const [firstTopic, secondTopic] = ["scope", "closure"];

data: lessonData means “read property data into binding lessonData.” It does not create a binding named data.

Enhanced object literals

const title = "Scope";
const minutes = 20;

const lesson = {
  title,
  minutes,
  [`${title.toLowerCase()}Id`]: 1,
  describe() {
    return `${this.title}: ${this.minutes}`;
  },
};

Shorthand properties use an in-scope binding with the same name. Computed keys evaluate an expression. Method syntax creates an ordinary method-style function, not an arrow.

Optional chaining and nullish assignment

const town = learner?.address?.town ?? "Unknown";

settings.theme ??= "system";
cache.current ??= createCache();

??= assigns only when the current value is nullish. Logical assignments ||= and &&= use their corresponding truthy logic.

Maps and Sets

Use Map for key/value collections where keys are not limited to strings/symbols or where collection semantics are clearer.

const attempts = new Map();
attempts.set(learner, 3);
console.log(attempts.get(learner));

const completedIds = new Set(["l1", "l2", "l1"]);
console.log(completedIds.size); // 2

Map and Set compare keys/values using SameValueZero semantics, so NaN matches itself and 0 matches -0. Object keys still use identity.

BigInt

bigint represents integers beyond Number's safe integer range.

const huge = 9_007_199_254_740_993n;
console.log(huge + 1n);

Do not mix number and bigint arithmetic without explicit conversion, and remember JSON does not natively serialise BigInt.

Regular expressions and Unicode awareness

const lessonCode = /^JS-\d{4}$/u;
console.log(lessonCode.test("JS-2026")); // true

Text is more complex than visible characters. String length counts UTF-16 code units, not necessarily user-perceived characters. Use Intl APIs for locale-aware formatting, comparison and segmentation rather than inventing English-only rules.

const formatter = new Intl.NumberFormat("en-GB", {
  style: "percent",
  maximumFractionDigits: 0,
});
console.log(formatter.format(0.75)); // "75%"

18. ES modules: explicit boundaries and live bindings

Modules divide a program into files with declared dependencies and exports.

// progress.js
let completed = 0;

export function completeLesson() {
  completed += 1;
}

export function getCompleted() {
  return completed;
}
// app.js
import { completeLesson, getCompleted } from "./progress.js";

completeLesson();
console.log(getCompleted()); // 1

Imports are live read-only views of exported bindings, not independent copied variables. The exporting module owns reassignment.

// counter.js
export let count = 0;
export function increment() { count += 1; }

// consumer.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1

Named and default exports

Named exports make the imported name explicit and support several exports per module. A default export provides one distinguished value and lets the importer choose its local name. Teams often prefer named exports for refactorability and consistency, but both are language features.

export function parseLesson() {}
export function validateLesson() {}

import { parseLesson, validateLesson } from "./lesson.js";

Static and dynamic imports

Static imports are analysed before execution and normally appear at module top level. Dynamic import() returns a Promise and loads conditionally.

async function openEditor() {
  const { createEditor } = await import("./heavy-editor.js");
  return createEditor();
}

Dynamic imports support code splitting in bundlers and optional features. Do not split tiny files blindly; each boundary adds loading and failure considerations.

Cycles and top-level await

Modules can form cycles, but reading a binding before it is initialised can fail and cycles make execution order harder to understand. Refactor shared contracts into a lower-level module when two modules depend on each other.

Top-level await allows a module to pause its evaluation, but importers must wait as well. Use it when module initialisation genuinely depends on asynchronous setup, not as a default data-fetching style that quietly delays an entire dependency graph.

Beginner definition: An ES module is a file with its own scope that explicitly exports selected bindings and imports dependencies. Imports are linked, live bindings rather than global variables.


19. Performance: measure the system, not syntax trivia

Performance includes loading, parsing, network latency, rendering, memory, responsiveness and algorithmic cost. Replacing a readable loop because a tiny benchmark claims another loop is faster rarely improves user experience.

Start with complexity

// Repeated linear search: can become O(n²)
const enrolled = learners.filter(learner =>
  enrolledIds.includes(learner.id),
);

// Build a Set once: approximately O(n)
const enrolledSet = new Set(enrolledIds);
const enrolledFast = learners.filter(learner => enrolledSet.has(learner.id));

For small arrays either is fine. At large scale, selecting the right data structure matters more than syntax micro-optimisation.

Avoid blocking the main thread

Break expensive work, move it to a worker or perform it on the server. Long synchronous tasks prevent input, animation and rendering.

// worker.js
self.onmessage = event => {
  const result = expensiveAnalysis(event.data);
  self.postMessage(result);
};
const worker = new Worker(new URL("./worker.js", import.meta.url), {
  type: "module",
});

worker.postMessage(largeDataset);
worker.onmessage = event => renderResult(event.data);

Data sent to workers uses structured cloning or transferable objects; it is not ordinary shared mutable state by default.

Debounce and throttle

Debouncing waits for a quiet period before running:

function debounce(operation, delay) {
  let timerId;

  return function debounced(...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => operation.apply(this, args), delay);
  };
}

const search = debounce(query => loadResults(query), 250);

Notice the closure over timerId, rest parameters and deliberate preservation of dynamic this with apply. Throttling instead limits an operation to at most once per interval. Choose based on desired interaction semantics.

Benchmark carefully

Modern engines warm up, optimise, de-optimise and perform garbage collection. A benchmark should use realistic data, isolate the operation, run enough samples and report distributions. Most importantly, connect it to a user-facing or resource goal.

Browser Performance tools, real-user monitoring, Node profiling, flame graphs and production traces reveal actual bottlenecks. Begin with evidence.

Memory and reachability

Garbage collection reclaims unreachable objects, not objects we no longer care about. Forgotten timers, event listeners, caches and subscriptions can keep graphs reachable.

function mountSearch(input) {
  const controller = new AbortController();
  const listener = () => search(input.value, controller.signal);
  input.addEventListener("input", listener);

  return () => {
    controller.abort();
    input.removeEventListener("input", listener);
  };
}

Lifecycle cleanup is correctness first and performance second.


20. How these mechanisms appear in React, Angular, Node and TypeScript

Frameworks do not replace JavaScript. They organise it.

React

React function components rely on lexical scope and closure. Event handlers capture render snapshots. Dependency arrays describe reactive values used by an effect. Immutable updates depend on reference identity.

function LessonButton({ lessonId }: { lessonId: string }) {
  const [completed, setCompleted] = useState(false);

  async function handleClick() {
    await api.completeLesson(lessonId); // closure captures lessonId
    setCompleted(true);
  }

  return (
    <button onClick={handleClick} disabled={completed}>
      {completed ? "Completed" : "Complete lesson"}
    </button>
  );
}

Angular

Angular components are class instances, so template event methods use the component receiver. RxJS callbacks use closure, and Observable work is not identical to Promise work.

export class LessonComponent {
  lessonId = input.required<string>();
  completed = signal(false);

  complete(): void {
    this.lessonService.complete(this.lessonId()).subscribe({
      next: () => this.completed.set(true),
      error: error => this.messages.show(error),
    });
  }
}

The arrow callbacks capture the method's this. A regular callback passed without care could receive a different this.

Node.js

Node uses JavaScript's language jobs plus its own event-loop phases and APIs. Async I/O lets one process coordinate many connections, while CPU-heavy JavaScript can still block an event-loop thread.

app.get("/api/lessons/:id", async (req, res, next) => {
  try {
    const lesson = await repository.findById(req.params.id);
    if (!lesson) return res.status(404).json({ error: "NOT_FOUND" });
    return res.json({ data: lesson });
  } catch (error) {
    return next(error);
  }
});

The handler closes over repository; the async function returns a Promise; request data remains untrusted strings; and database I/O does not imply JavaScript worker-thread parallelism.

TypeScript

TypeScript adds static analysis and erased type syntax. Runtime data still needs validation.

type Lesson = {
  id: string;
  title: string;
};

async function loadLesson(id: string): Promise<Lesson> {
  const response = await fetch(`/api/lessons/${id}`);
  const data: unknown = await response.json();
  return LessonSchema.parse(data);
}

Writing const data = await response.json() as Lesson makes a claim; it does not inspect the JSON. TypeScript does not change coercion, prototypes, closure, this or the event loop.


21. Errors, debugging and defensive boundaries

An error is not merely red text in the console. It is a value describing a failure, usually represented by an Error object. JavaScript provides built-in subclasses including TypeError, RangeError, ReferenceError, SyntaxError and AggregateError.

function lessonAt(lessons, index) {
  if (!Number.isInteger(index)) {
    throw new TypeError("Lesson index must be an integer");
  }
  if (index < 0 || index >= lessons.length) {
    throw new RangeError(`Lesson index ${index} is outside the list`);
  }
  return lessons[index];
}

Choose an error type that describes the violated contract. Include useful context without exposing passwords, tokens or private data.

Throwing and catching

throw causes abrupt completion. Control moves up through calling functions until a matching catch is found. If nothing catches it, the host reports an uncaught error.

function parseSettings(text) {
  try {
    const value = JSON.parse(text);
    if (value === null || typeof value !== "object") {
      throw new TypeError("Settings must be an object");
    }
    return value;
  } catch (error) {
    throw new Error("Could not read learner settings", { cause: error });
  }
}

The cause keeps the original failure available while adding domain context. Catch an error when the current layer can recover, translate it, add meaningful context or report it. Do not catch merely to print and continue with corrupted state.

JavaScript allows throwing any value:

throw "failed"; // legal, but poor practice

Prefer Error objects because they carry a message, stack and consistent shape. In TypeScript, a caught value should be treated as unknown and narrowed:

try {
  await saveProgress();
} catch (error: unknown) {
  const message = error instanceof Error ? error.message : "Unknown failure";
  showMessage(message);
}

Expected results versus exceptions

Invalid user input is often expected and can be represented as data:

function parseMinutes(text) {
  const value = Number(text);
  if (!Number.isFinite(value) || value < 0) {
    return { ok: false, error: "Enter zero or a positive number" };
  }
  return { ok: true, value };
}

An unavailable database or broken invariant is exceptional and may be thrown. There is no universal law; define contracts so callers know which outcomes are returned and which failures reject or throw.

Debug from evidence

When a program surprises me, I avoid changing several lines at once. I follow a repeatable method:

  1. Write the expected and observed behavior.
  2. Reduce the input to the smallest reproduction.
  3. Find the first point where state differs from expectation.
  4. Inspect value and type with deliberate logging or a debugger.
  5. Check scope, receiver, prototype and scheduling.
  6. Form one hypothesis that could be disproved.
  7. Make one change and rerun.
  8. Add a test for the corrected behavior.
console.log({
  value: input,
  type: typeof input,
  isArray: Array.isArray(input),
  ownKeys: input && typeof input === "object" ? Reflect.ownKeys(input) : [],
});

Use breakpoints to inspect the call stack and scopes. Use the Network panel for request timing and payloads. Use console.trace() when an unexpected call path matters. Do not leave noisy logs containing sensitive user information.

Assertions and invariants

An invariant is a condition that must remain true for the model to be valid.

function invariant(condition, message) {
  if (!condition) throw new Error(`Invariant failed: ${message}`);
}

function calculateProgress(completed, total) {
  invariant(Number.isInteger(completed), "completed must be an integer");
  invariant(Number.isInteger(total), "total must be an integer");
  invariant(total >= 0, "total cannot be negative");
  invariant(completed >= 0 && completed <= total, "completed must be in range");
  return total === 0 ? 0 : completed / total;
}

Assertions are developer-facing checks, not substitutes for friendly input validation. They make assumptions executable and turn distant corruption into a nearby failure.

Beginner definition: An exception is an abrupt failure that travels up the call stack until handled. Debugging is the disciplined process of locating the first incorrect assumption using observable evidence.


22. Identity, shallow copying and collection choices

Objects are compared by identity, not by recursively comparing contents.

console.log({ title: "Scope" } === { title: "Scope" }); // false

const lesson = { title: "Scope" };
const same = lesson;
console.log(lesson === same); // true

The first expression creates two distinct objects. They happen to contain equal-looking properties, but they do not have the same identity.

This matters in React dependency checks, Map keys, Set membership, memoisation and caches.

const selected = new Set();
selected.add({ id: "l1" });
console.log(selected.has({ id: "l1" })); // false: another object

If domain identity is the lesson ID, store IDs or reuse canonical object references:

const selectedIds = new Set(["l1"]);
console.log(selectedIds.has("l1")); // true

Shallow copying

Spread creates a shallow copy:

const original = {
  title: "Scope",
  author: { name: "Faz" },
};

const copy = { ...original };
copy.title = "Closure";
copy.author.name = "Amina";

console.log(original.title);       // "Scope"
console.log(original.author.name); // "Amina"

The outer object is new, but both outer objects refer to the same nested author. Copy the path being changed when immutable updates are required:

const updated = {
  ...original,
  author: {
    ...original.author,
    name: "Amina",
  },
};

structuredClone performs a deeper structured clone for supported values and handles cycles, Maps, Sets and several built-ins. It does not clone functions or every host object, and it is not automatically the right tool for every state update.

const clone = structuredClone({
  createdAt: new Date(),
  tags: new Set(["javascript"]),
});

JSON stringify/parse is not a general deep-clone algorithm. It loses undefined, symbols and functions, converts dates to strings, fails on BigInt and cycles, and depends on JSON serialization rules.

Array operations

Some methods return new arrays:

const topics = ["scope", "closure"];
const added = [...topics, "prototype"];
const upper = topics.map(topic => topic.toUpperCase());
const filtered = topics.filter(topic => topic !== "scope");

Other methods mutate the existing array:

topics.push("prototype");
topics.sort();
topics.reverse();

Modern copying alternatives such as toSorted, toReversed, toSpliced and with return new arrays:

const sorted = topics.toSorted();
const replaced = topics.with(0, "bindings");

Knowing whether an operation mutates is essential when identity signals change to a framework or cache.

Choose the collection by the question

  • Array: ordered sequence, duplicates allowed.
  • Object: record with known string/symbol fields.
  • Map: dynamic key/value collection, any key type, explicit collection API.
  • Set: unique-value membership.
  • WeakMap/WeakSet: object-key associations that should not keep keys alive; not enumerable.
const metadata = new WeakMap();

function attachMetadata(element, data) {
  metadata.set(element, data);
}

When the element becomes otherwise unreachable, the WeakMap entry does not prevent collection. Weak collections are deliberately not enumerable because garbage collection must remain unobservable.

Beginner definition: Object identity answers whether two references point to the exact same object. A shallow copy creates a new outer collection while preserving references to nested objects.


23. A complete StudyDesk example

The following small design combines the mechanisms without hiding them behind a framework.

// lesson-store.js
export function createLessonStore(initialLessons = []) {
  let lessons = [...initialLessons];
  const listeners = new Set();

  function emit() {
    const snapshot = lessons.map(lesson => ({ ...lesson }));
    for (const listener of listeners) listener(snapshot);
  }

  return Object.freeze({
    getSnapshot() {
      return lessons.map(lesson => ({ ...lesson }));
    },

    add(lesson) {
      if (!lesson?.id || !lesson?.title) {
        throw new TypeError("A lesson requires id and title");
      }
      if (lessons.some(existing => existing.id === lesson.id)) {
        throw new Error(`Duplicate lesson ${lesson.id}`);
      }
      lessons = [...lessons, { ...lesson, completed: false }];
      emit();
    },

    complete(id) {
      let found = false;
      lessons = lessons.map(lesson => {
        if (lesson.id !== id) return lesson;
        found = true;
        return { ...lesson, completed: true };
      });
      if (!found) throw new Error(`Unknown lesson ${id}`);
      emit();
    },

    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  });
}

Mechanisms present:

  • Module scope prevents accidental globals.
  • createLessonStore is a factory.
  • lessons and listeners are private through closure.
  • Each store call creates independent state.
  • Set prevents duplicate listeners.
  • Returned methods share one lexical environment.
  • Immutable array/object copies create new identities.
  • Optional chaining checks a possibly nullish input.
  • Listener callbacks invert control, so subscribe returns cleanup.
// api.js
export async function loadLessons(signal) {
  const response = await fetch("/api/lessons", { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const payload = await response.json();
  if (!Array.isArray(payload.data)) {
    throw new TypeError("Expected an array of lessons");
  }
  return payload.data;
}
// app.js
import { createLessonStore } from "./lesson-store.js";
import { loadLessons } from "./api.js";

const controller = new AbortController();

try {
  const lessons = await loadLessons(controller.signal);
  const store = createLessonStore(lessons);

  const unsubscribe = store.subscribe(snapshot => {
    renderLessonList(snapshot, {
      onComplete: id => store.complete(id),
    });
  });

  renderLessonList(store.getSnapshot(), {
    onComplete: id => store.complete(id),
  });

  window.addEventListener("pagehide", () => {
    controller.abort();
    unsubscribe();
  }, { once: true });
} catch (error) {
  renderError(error instanceof Error ? error.message : "Unknown error");
}

This is not offered as a universal state library. It is a readable laboratory. We can point to each binding, call site, closure, async boundary and cleanup rule.


24. Beginner questions answered precisely

What is a binding?

A binding is an association between an identifier such as score and a value within an environment. Saying “variable” is usually fine; “binding” reminds us that scope rules resolve the name and that const protects the association rather than deeply freezing the value.

What is lexical scope?

Lexical scope means that where code is written determines which outer bindings it may access. A nested function can use names from enclosing scopes even if another function calls it elsewhere.

What is closure?

Closure is a function's continuing access to bindings from the lexical environment where it was created. It powers callbacks, configured functions, private state, modules and framework handlers.

What is this?

this is a special function value selected mainly by the call site. object.method() supplies object as receiver. A detached method() call does not. Arrow functions inherit this from surrounding code.

What is a prototype?

A prototype is an object used as another object's fallback for property lookup. Objects form chains of these links. Constructor .prototype is the object used as the internal prototype of instances created with that constructor and new.

Is a class different from a prototype?

A class is a higher-level syntax for constructors, shared prototype methods, inheritance and private/static members. Its instances still participate in prototype chains at runtime.

What is coercion?

Coercion is conversion between types. Number("42") is explicit. "42" * 1 triggers implicit conversion. Clear boundary conversion and validation make code dependable.

What is a callback?

A callback is a function passed to other code for that code to invoke. Array methods call callbacks immediately during processing; event systems and timers may call them later.

What is a Promise?

A Promise is an object representing one eventual success value or failure reason. It lets asynchronous outcomes be composed without giving another API unrestricted control of the whole continuation.

What is the event loop?

The event loop is the host scheduling model that lets queued work run after the current JavaScript job completes. Promise reactions are jobs/microtasks; timers and events are scheduled through host task mechanisms.

Is JavaScript single-threaded?

An individual JavaScript agent executes one job at a time, but a host can perform I/O concurrently and can create other agents such as workers. “Single-threaded” is a useful starting phrase but not a complete description of the whole runtime.

Does TypeScript replace JavaScript knowledge?

No. TypeScript improves compile-time feedback. The emitted program follows JavaScript rules, and incoming runtime data remains untrusted.


25. Practice drills: prediction before execution

Drill 1: scope

const value = "outer";

function test() {
  const value = "inner";
  return () => value;
}

const read = test();
console.log(read());

Define which binding is captured and why. Then rename the inner binding and predict again.

Drill 2: independent closures

function createScore() {
  let score = 0;
  return amount => score += amount;
}

const a = createScore();
const b = createScore();

console.log(a(2));
console.log(a(3));
console.log(b(10));

Explain why b does not begin at five.

Drill 3: call site

const person = {
  name: "Faz",
  read() { return this.name; },
};

const read = person.read;
console.log(person.read());
console.log(read.call({ name: "Amina" }));

Do not say “this belongs to the function.” Identify each receiver.

Drill 4: prototype lookup

const base = { level: 1 };
const child = Object.create(base);
child.topic = "scope";

console.log(child.level);
child.level = 2;
console.log(base.level, child.level);
delete child.level;
console.log(child.level);

For every line, state whether level is own, inherited or shadowed.

Drill 5: coercion

console.log("5" + 1);
console.log("5" - 1);
console.log(Boolean(""));
console.log([] == false);

Run the first three after predicting. Research and carefully trace the last; then write the explicit production code you would prefer.

Drill 6: scheduling

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
console.log(5);

Write the output order and explain the current job, microtask order and later timer task.

Drill 7: Promise composition

Rewrite three nested callbacks using a Promise chain, then with async/await. Add a timeout/abort requirement and an error that contains the original cause.

Drill 8: build without a framework

Create a lesson list with:

  • one module for validated API access;
  • one closure-based store;
  • one renderer with event delegation;
  • one cleanup function;
  • loading, empty, success and error states;
  • an AbortController for navigation;
  • no global mutable variables.
Explain every use of scope, closure, this, prototypes and scheduling. If an explanation is vague, that is the next study topic.

26. A gentle revision map

Do not attempt to memorise this entire guide in one sitting.

Pass one: vocabulary

Be able to define value, type, binding, expression, statement, function, parameter, argument, scope, closure, receiver, object, property, prototype, coercion, callback, Promise, iterator and module in one or two sentences.

Pass two: prediction

Take small examples and predict output. Write down the exact scope lookup, property lookup, receiver or queue that determines the answer. Prediction exposes the difference between recognition and understanding.

Pass three: construction

Build counters, validators, object delegations, Promise workflows and iterables from a blank file. Change one detail and observe the consequence.

Pass four: framework connection

Find each mechanism in a real React, Angular or Node codebase. Identify closures in event handlers, prototype methods in classes, coercion at HTTP boundaries and concurrency in API orchestration.

Pass five: teaching

Explain one topic slowly to an imaginary beginner. Avoid saying “it remembers,” “it just knows,” “JavaScript is weird” or “the framework handles it.” Name the binding, environment, receiver, prototype or queued job.


27. Final mastery checklist

I understand JavaScript's core when I can:

  • distinguish ECMAScript features from browser and Node host APIs;
  • name all eight modern language types and identify the seven primitives;
  • explain why typeof null is a historical quirk;
  • distinguish copying a primitive from copying an object reference;
  • choose const, let and var with knowledge of their scope behavior;
  • explain lexical lookup, shadowing and module scope;
  • describe hoisting without claiming source lines literally move;
  • define the temporal dead zone;
  • build and explain two independent closures;
  • diagnose a stale closure in a UI callback;
  • determine ordinary-function this from the call site;
  • explain why arrows are useful callbacks but poor dynamic methods;
  • trace own and inherited property lookup;
  • distinguish an internal prototype from a constructor's .prototype property;
  • explain how class methods relate to prototype objects;
  • convert and validate form or JSON data at runtime;
  • choose ===, Object.is or a deliberate coercive comparison;
  • distinguish || from ??;
  • predict tasks and Promise microtasks at a practical level;
  • compose sequential and concurrent Promise work deliberately;
  • cancel supported work with AbortController;
  • implement and consume an iterable;
  • use module boundaries and dynamic imports intentionally;
  • measure real bottlenecks rather than repeating performance folklore;
  • explain why TypeScript strengthens but does not replace JavaScript.
JavaScript becomes less mysterious when we stop collecting rules as trivia and connect them into mechanisms. Scope tells us where a name comes from. Closure tells us why that access can continue. The call site tells us what this means. The prototype chain tells us where a missing property is found. Coercion tells us why an operator chose a particular behavior. The event loop tells us when deferred work can continue.

That is the real goal of deep JavaScript study: not the ability to win a puzzle contest, but the ability to predict normal application code, explain it kindly to someone else and repair it confidently when production disagrees with our assumptions.


Current references and further study

The official second-edition repository now describes the series as complete. It lists *Get Started*, *Scope & Closures*, the stable draft *Objects & Classes*, and a rough draft of *Types & Grammar*. It marks the planned second-edition *Sync & Async* and *ES.Next & Beyond* books as cancelled. The six first-edition books remain historically valuable, but modern study should pair them with the current repository, living ECMAScript specification and current platform documentation.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Continuous Learning →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise JavaScript and frontend questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →