How it works
The spec has one instantiation operation per scope kind, each run before the scope's first statement:
- GlobalDeclarationInstantiation for scripts.
- FunctionDeclarationInstantiation for every function call.
- BlockDeclarationInstantiation for blocks,
switch bodies, and so on.
- Module and
eval code have their own variants.
Each follows the same pattern. First it collects the names. Var-scoped declarations are var and top-level function declarations; lexically scoped ones are let, const, class, and block-level functions. Next it checks for conflicts, which are early errors (a SyntaxError for a let that clashes with a var in the same scope). Then it creates bindings. Function declarations are created and initialized straight away with InstantiateFunctionObject. Plain var names are initialized to undefined only if the name isn't a parameter or an already-declared function. let, const, and class get uninitialized bindings, and InitializeBinding runs later when the declaration is evaluated.
That ordering explains the classic puzzles:
function order() {
console.log(typeof item); // → function (the function wins at instantiation)
var item = 1;
console.log(typeof item); // → number (the assignment runs in place)
function item() {}
}
order();
function param(value) {
var value; // no initializer, so the parameter's value survives
return value;
}
console.log(param(5)); // → 5
Function declarations are processed in source order and the last one with a name wins, so function a(){}; function a(){} leaves the second.
Edge cases
- Block-level functions in strict code. Inside a block, a function declaration is lexically scoped like
let, and is initialized at block entry. Outside the block the name isn't visible:
console.log(typeof inner); // → undefined
{
console.log(typeof inner); // → function (initialized at block entry)
function inner() {}
}
console.log(typeof inner); // → undefined
- Annex B for sloppy code. Legacy: in non-strict scripts on web-compatible hosts, a block function also creates a function-scoped
var binding (initialized to undefined) if that wouldn't cause an early error. When the declaration is evaluated, the block's value is copied to that var. Node's CommonJS files are sloppy, so this runs there too, but ES modules don't see it:
// Sloppy script or CommonJS file, not an ES module
console.log(typeof legacy); // → undefined (the var exists, but holds no function yet)
if (true) {
function legacy() {}
}
console.log(typeof legacy); // → function
- Default parameters create a second environment for the body's
vars, so a body var x doesn't share a binding with a parameter-scope closure. This is covered in the scope-chain question.
class and let at the top of a block shadow outer names from the first line of the block, even before the declaration, which is why { console.log(x); let x; } throws instead of reading an outer x.
Trade-offs
Function-declaration hoisting lets you put the main logic first and helpers below, and it makes mutually recursive functions work with no ordering. The cost is that var hoisting hides bugs: using a variable before assignment gives undefined instead of an error. Modern style (const for values, function declarations or arrows after use) leans on each rule where it is safe.