Cannot access before initialization? let, const and the Temporal Dead Zone
Kai changed var to const and his page crashed. Raiden, Domlok's Lightning guide, explains hoisting, the temporal dead zone and why let and const throw "Cannot access before initialization", then fixes it by putting declarations first.
Told by Raiden, with Kai asking the questions
Raiden: A name can exist before it has a value, Kai. Reach for it in that gap and the ghost answers instead.
If your page crashes with ReferenceError: Cannot access 'X' before initialization, your code is reading a let, const or class before the line that declares it has run. The fix is to put declarations before the code that uses them, not to switch back to var.
In the world of Domlok this is the work of the Dead Zone Yurei, a pale ghost that haunts the space between the top of a scope and the line where a variable gets its value. Under var she stays quiet and hands you undefined. Under const she screams. That scream is the helpful part.
Your guide for this episode is Raiden, the Lightning guide of Domlok, who rules over the JavaScript runtime: events, timers, closures and the exact order in which code runs. Hoisting and the temporal dead zone are about when the engine creates names and when it fills them in, which is Raiden’s ground. Kai is the one who tidied up the code and woke the ghost.
The ghost on line one
Kai’s booking page loads data as soon as the script starts. He calls init() at the very top so it runs first, and declares the API address just below. Then he follows the style guide and changes var to const:
init(); // load bookings first
const API_URL = '/api/bookings';
function init() {
fetch(API_URL).then((r) => r.json()).then(render);
}Uncaught ReferenceError: Cannot access 'API_URL'
before initialization at init(app.js: 6) at app.js: 1KaiWith var it didn’t crash. Const broke my page!
RaidenWith var the fetch went to /undefined and nobody noticed. Var hid the bug. Const told you the truth.
Read it in the order it runs. Line 1 calls init(). That works, because function declarations are ready before any line runs. Inside, init() reads API_URL, but line 3 has not run yet. With var, the variable already exists with the value undefined, so fetch(undefined) quietly requests a page called undefined. With const, the variable exists but has no value at all, and reading it throws.
Every name is hoisted, not every value
When JavaScript enters a scope (a script, a module, a function or a block), it first sets up every name declared in it, then runs the code line by line. This is what people call hoisting. What each name holds before its line runs depends on how it was declared:
| Declaration | Before its line runs | Read it early |
|---|---|---|
function f() {} |
the whole function | works, you can call it |
var x |
undefined |
gives undefined, no error |
let, const, class |
no value (uninitialized) | ReferenceError |
import |
the imported binding | works (the module is evaluated first) |
The stretch from the start of the scope to the line where a let, const or class gets its value is the temporal dead zone (TDZ). The name is there, it even blocks outer variables with the same name, but any read or write throws.
KaiSo the ghost isn’t missing. It’s there, it just has no value yet.
Temporal means time, not position
The zone is about when code runs, not where it is written. A function written above a declaration can safely use it, as long as it is called after the declaration has run:
const show = () => console.log(API_URL);
const API_URL = '/api';
show(); // '/api'const show = () => console.log(API_URL);
show(); // ReferenceError const API_URL = '/api';Same lines, different moment. Two more places the ghost hides:
typeofis not safe in the TDZ.typeof neverDeclaredreturns'undefined', buttypeof xthrows ifxis aletorconststill in its dead zone.- A block shadows from its first line. A
letinside a block claims that name for the whole block, so code above it in the same block can’t see the outer variable.
let count = 1;
{
console.log(count); // ReferenceError
let count = 2; // claims count for the whole block
}RaidenGive the inner one a new name, or declare it at the top of the block before you use it.
The fix: declare, then use
Put things in the order they run. Constants and setup go at the top, and the call that starts everything goes last:
const API_URL = '/api/bookings';
function init() {
fetch(API_URL).then((r) => r.json()).then(render);
}
init(); // last: everything it needs is setThen let your editor catch the next one. ESLint’s no-use-before-define rule reports any name used before its declaration. It is not in eslint:recommended, so turn it on yourself:
export default [ { rules: { 'no-use-before-define': 'error', }, }, ];The same error from circular imports
If you see this error in a bundled app with no obvious ordering problem, look for two modules that import each other. When a.js imports b.js and b.js imports a.js, one of them runs first and can read the other’s const before that module has finished. Move the shared value into a third module both can import, or only read it later, inside a function that runs after start-up.
KaiConstants on top, init at the bottom. The bookings are back!
Catch the Yurei in DevTools
- Read the Console. The error names the variable. Click the
app.js:6link to jump to the line that read it. - Pause on the exception. In the Sources panel, open Breakpoints, tick Pause on uncaught exceptions and reload. DevTools stops on the line that threw.
- Read the Call Stack. It shows who called too early: here
init, called from line 1 ofapp.js, before line 3 had run.
Different browsers word it differently, but it is the same ghost:
| Browser | Message |
|---|---|
| Chrome, Edge (V8) | Cannot access 'X' before initialization |
| Firefox | can't access lexical declaration 'X' before initialization |
| Safari | Cannot access uninitialized variable. |
Before you merge
- Is every
let,constandclassdeclared before the first code that runs and reads it? - Is the call that starts your app (
init(),mount(),render()) at the bottom of the file? - Did you fix the order instead of switching back to
var? - Is
no-use-before-defineon, and are there no modules that import each other?
Acharya's Rule #19Declare before you use. Hoisting makes the name, not the value.
Next episode: The Bloat Asura. Why your bundle got so big, and how tree shaking, code splitting with dynamic import() and a dependency audit cut it down.
Think you can beat the Yurei yourself? Play Raiden’s Dead Zone on domlok.in and earn seal 19.
Watch the full episode and the 33-second version on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.
Further reading
- MDN: let and the temporal dead zone
- MDN: ReferenceError: can’t access lexical declaration ‘X’ before initialization
- MDN Glossary: Hoisting
- ESLint: no-use-before-define