Frontend Development

JavaScript Language Semantics Refresher

15 min
The AI Space Team

This post refreshes the parts of JavaScript that explain most “wait…why?” moments in interviews and real debugging: execution contexts, scope, hoisting, closures, and this.

JavaScript Language Semantics Refresher

Modern JavaScript feels like an interpreted language, but what you observe at runtime (“why is this undefined?”, “why does this throw ReferenceError?”, “why does this closure keep memory?”) is best explained with a small set of semantics:

  • Execution contexts + call stack (where code runs)

  • Environment records (where names live)

  • Binding vs initialization vs assignment (what “hoisting” actually is)

  • Lexical scope + closure (how name lookup and captured variables work)

  • this binding rules (call-site, strict mode, modules)

This post is a language semantics refresher: spec-aligned mental models + practical interview patterns.


Cheat sheet (2–3 minutes revision)

  • Execution context: “a frame where code runs.” Global context + one per function call. Each context has LexicalEnvironment and VariableEnvironment(spec model).

  • Call stack: contexts are pushed on call, popped on return (LIFO).

  • Lexical scope: names resolve based on where functions are declared, not how they’re called.

  • Hoisting (useful model) = bindings created during setup before statements execute.

  • var: binding created + initialized to undefined during setup → reading early gives undefined.

  • let/const/class: binding exists but uninitialized until declaration line executes → TDZ → ReferenceError.

  • Function declarations: initialized to the function value during setup → callable before the line.

  • Closure: function + references to surrounding lexical environment (captured variables).

  • this: depends on call-site, not lexical scope. In modules, top-level this is undefined.


1) Classic script vs ES module (why this matters for semantics)

In the browser, the same .js file can be evaluated in different modes:

Classic script

<script src="app.js"></script>

  • Historically the “default” script mode.

  • Top-level this is generally the global object (in browsers, window / globalThis), and legacy behaviors are more visible.

ES module

<script type="module" src="app.js"></script>

  • Enables native import / export module semantics.

  • Top-level this is undefined.

  • Module code is treated more like “modern JS”: strict behaviors are the norm, and top-level declarations don’t automatically become global properties in the same way.

Why you should care: the same snippet involving this, globals, or “what’s in the global scope” can behave differently depending on whether the file is a classic script or a module.


2) JS “interpreted” but still has setup work (compile-ish → execute)

Even without thinking about V8 internals, the JS execution model is commonly explained as:

  1. Setup/instantiation (before statements run) The runtime prepares the execution context and scope bindings.

  2. Execution (run statements top to bottom) Values get computed, assignments happen, functions are called.

MDN explicitly notes the execution model is abstract/theoretical and engines heavily optimize it, but it’s still the best way to reason about semantics.


3) Execution contexts and the call stack

Execution context

When JavaScript runs code, it runs inside an execution context:

  • A global execution context is created to run the top-level program.

  • Each time a function is called, a function execution context is created.

The ECMAScript spec models each execution context with (at least) these components:

  • LexicalEnvironment

  • VariableEnvironment

Call stack

The runtime keeps an execution context stack:

  • Calling a function pushes a new context.

  • Returning from a function pops it.

function a() { b(); }
function b() { c(); }
function c() { /* ... */ }

a();

Call stack evolution (conceptually):

  • push global

  • call a() → push a

  • call b() → push b

  • call c() → push c

  • return c → pop

  • return b → pop

  • return a → pop

Why this matters: hoisting/scope/closures are easiest to understand as “what bindings exist in the current execution context’s environments, and what does lookup do as we walk outward?”


4) Scope in modern JS: global, function, block, module

  • Lexical scope means identifier resolution is based on where code is written (not how it’s called).

  • Scopes come from syntax constructs:

    • global scope

    • function scope

    • block scope ({ ... } with let/const/class)

    • module scope (top-level of an ES module)

The spec models nested scopes using Environment Records linked by [[OuterEnv]] references.


5) Hoisting demystified: binding vs initialization vs assignment

MDN’s glossary definition captures the effect (“appears to move declarations to the top”), but the deeper explanation is more useful for interviews and real debugging.

Key terms

  • Binding: the scope now has a name (identifier) registered.

  • Initialization: the binding becomes usable with a value state.

  • Assignment: execution sets/updates the value.

Hoisting:

During scope setup, bindings are created before statement execution. Different declaration forms initialize differently.

The rules (what actually differs)

Declaration

Scope

Binding created

Initialized when

Reading before declaration

var x

function / global (script)

setup

setup →

undefined

✅ returns

undefined

let x

block

setup

when declaration executes

❌ ReferenceError (TDZ)

const x

block

setup

when declaration executes (must)

❌ ReferenceError (TDZ)

function f(){}

function / block*

setup

setup → function object

✅ callable

class C {}

block

setup

when declaration executes

❌ ReferenceError (TDZ)


var: hoisted + initialized to undefined

MDN states var declarations are processed before code runs, which is why variables may appear usable before the declaration line.

console.log(x); // undefined
var x = 10;
console.log(x); // 10

This explains classic interview puzzles like:

var myname = "Global";

function showName() {
  console.log(myname); // undefined (local var exists, initialized to undefined)
  if (0) {
    var myname = "Hello";
  }
  console.log(myname); // undefined
}

showName();

let/const/class: hoisted bindings + TDZ (uninitialized until the line)

MDN defines the Temporal Dead Zone: from entering the block until execution reaches the declaration/initialization, accessing the variable throws.

console.log(x); // ReferenceError (TDZ)
let x = 10;

const also requires initialization at the declaration:

const a = 1; // OK
// const b;  // SyntaxError

Function declaration vs function expression

Function declarations are “hoisted with their value” (you can call them before the line).

hello();              // works
function hello() { console.log("hi"); }

Function expressions follow the variable’s hoisting rules:

hi();                 // TypeError: hi is not a function
var hi = function () {};

  • hi exists and is undefined during setup → calling it fails.


6) Lexical scope and closure (and what it means for memory)

Lexical scope: lookup depends on where functions are declared

MDN’s closures guide (and spec-level environment chain model) matches this idea: inner functions can reference bindings from outer scopes.

const x = "global";

function outer() {
  const x = "outer";
  function inner() {
    console.log(x); // "outer"
  }
  return inner;
}

const fn = outer();
fn(); // "outer"

Closure: keeping outer bindings alive

A closure forms when a function retains access to variables from its defining scope, even after that outer function returns.

function makeCounter() {
  let count = 0;
  return function () {
    count++;
    return count;
  };
}

const c = makeCounter();
c(); // 1
c(); // 2

Workplace caution: closures don’t “leak” by default, but they can retain memory

JavaScript is garbage-collected: memory can be reclaimed when objects become unreachable. Closures become a practical issue when something keeps references alive longer than intended (e.g., long-lived event listeners, caches, singletons).

Rule of thumb:

  • If a closure is intentionally long-lived, keep it small and predictable.

  • If not, ensure you remove listeners / clear timers / drop references when done.


7) this is not scope: it’s a call-site binding (plus module/strict rules)

MDN’s this docs emphasize that top-level this differs in modules and that this depends on how a function is called.

The big 4 patterns (interview staples)

1) Plain function call

  • In sloppy mode (non-strict classic script), this becomes the global object.

  • In strict mode, this stays undefined.

"use strict";
function f() { return this; }
console.log(f()); // undefined

2) Method call

If you call via an object reference, this is that object (at call time):

const obj = {
  name: "A",
  show() { console.log(this.name); }
};
obj.show(); // "A"

3) call / apply / bind

You can explicitly set this:

function show() { console.log(this.name); }
const o = { name: "X" };
show.call(o); // "X"

4) new constructor call

new creates a new object, binds this to it, links prototypes, and returns it (unless the constructor returns a different object).

function Person(name) {
  this.name = name;
}
const p = new Person("Mandy");
console.log(p.name); // "Mandy"

Arrow functions: this is lexical (captured from outer scope)

Arrow functions don’t have their own this; they use the surrounding this. That’s why they’re commonly used for callbacks inside methods:

const obj = {
  name: "A",
  later() {
    setTimeout(() => {
      console.log(this.name); // "A"
    }, 0);
  }
};
obj.later();

Module gotcha: top-level this

In ES modules, top-level this is always undefined. This is a common interview “bonus” question.


8) Practical guidelines (what to do at work)

Prefer clarity over cleverness

  • Avoid relying on hoisting for readability.

  • Prefer const by default, let when you reassign, and avoid var in modern codebases.

Avoid accidental globals / confusing this

  • Use ES modules (default in most modern stacks) to reduce global-scope hazards.

  • In callbacks inside methods, use arrow functions (or .bind) when you need the surrounding this.

Treat closures as normal—but manage lifetimes

  • Closures are a feature, not a bug; just be mindful when a closure is held by something long-lived.


9) Interview questions (and the “why” behind them)

Hoisting / TDZ

Q: What’s the output?

console.log(a);
let a = 1;

A: ReferenceError due to TDZ (binding exists but not initialized until the declaration executes).

Q: What’s the output?

console.log(a);
var a = 1;

A: undefined (binding created + initialized to undefined during setup).

Function declaration vs expression

Q: Why does this work?

foo();
function foo() {}

A: Function declarations are initialized with their function value during setup (“hoisted with value”).

Closures + loops (classic)

Q: What prints?

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

A: 3 3 3 because var i is one function/global binding shared by all callbacks.

Follow-up fix: use let for block scoping:

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

this + strict mode

Q: What’s this in a plain function call under strict mode?

"use strict";
function f(){ return this; }
f();

A: undefined.

Module trivia (modern)

Q: In <script type="module">, what is top-level this? A: undefined.


References