Hoisting ist das Verhalten der JavaScript-Engine, das Deklarationen vor der eigentlichen Ausführung des Codes „nach oben zieht" — nicht physisch im Quelltext, sondern im Scope-Lookup, der vor jeder Execution Phase passiert. Der Begriff selbst kommt nicht aus der Spezifikation, sondern aus der Lehre. Was die Spezifikation tatsächlich beschreibt, sind getrennte Phasen für Creation und Execution. Dieser Artikel zeigt, wie sich die fünf relevanten Deklarations-Formen — var, let, const, function, class — beim Hoisting unterscheiden, was die Temporal Dead Zone wirklich ist und welche Anti-Patterns sich nur durch Hoisting-Wissen verstehen lassen.

Was ist Hoisting?

Wenn die Engine eine Funktion oder ein Modul lädt, durchläuft sie zwei logische Phasen: in der Creation Phase scannt sie den gesamten Code und legt für jede Deklaration im Scope einen passenden Eintrag an — Funktions-Body, Variablen-Slot, Class-Binding. Erst danach beginnt die Execution Phase, in der die Anweisungen Zeile für Zeile abgearbeitet werden. Der Effekt aus Sicht des Programmierers: Namen, die weiter unten deklariert sind, sind schon vorher bekannt. Genau dieses Phänomen heißt Hoisting.

Der genaue Charakter dieses „Bekanntseins" hängt von der Deklarations-Form ab. Die MDN unterscheidet vier Hoisting-Verhalten — von „voll nutzbar" (Funktionen) bis „existent, aber Zugriff wirft Fehler" (let, const, class).

JavaScript hoisting-grundidee.js
gruss(); // funktioniert — function-Hoisting

function gruss() {
    console.log('Hallo!');
}

console.log(zaehler); // undefined — var-Hoisting (mit undefined)
var zaehler = 5;

// console.log(name); // ReferenceError — TDZ
let name = 'Anna';
Output
Hallo!
undefined

Function-Hoisting — vollständig

Eine Function-Declaration (function name() { ... }) wird bei der Creation Phase vollständig gehoisted: Name und Body stehen ab dem ersten Moment im Scope zur Verfügung. Das ist Type-1-Hoisting nach MDN-Klassifikation und das einzige Hoisting, das aus Sicht des Aufrufers tatsächlich „magisch" wirkt.

JavaScript function-hoisting.js
// Aufruf VOR der Definition
console.log(addiere(2, 3)); // 5

function addiere(a, b) {
    return a + b;
}

// Auch verschachtelte Aufrufe und Rekursion funktionieren
console.log(fakultaet(5)); // 120

function fakultaet(n) {
    return n <= 1 ? 1 : n * fakultaet(n - 1);
}
Output
5
120

Praktisch nutzen viele Codebasen diese Eigenschaft, indem sie die Funktions-Definitionen unten und den Top-Level-Aufruf oben platzieren — ein Stil, den manche bevorzugen, andere verpönen.

var-Hoisting — Slot mit undefined

var-Deklarationen werden in der Creation Phase mit dem Wert undefined initialisiert. Das hat eine wichtige Konsequenz: Lesender Zugriff vor der Zuweisungs-Zeile wirft keinen Fehler, sondern liefert undefined. Ein Verhalten, das mehr Bugs versteckt als es löst.

JavaScript var-hoisting.js
console.log(typeof x); // 'undefined'
console.log(x);        // undefined — KEIN Fehler

var x = 5;

console.log(x);        // 5

// Mental wird das vom Compiler so umgeschrieben:
// var x;          // Creation Phase: Slot mit undefined
// console.log(x); // Execution Phase: liest aus Slot
// x = 5;          // Zuweisung
Output
undefined
undefined
5

Die berüchtigte Interview-Frage lautet: „Was gibt console.log(x); var x = 5; aus?" — Antwort: undefined, eben wegen des Hoistings.

let/const-Hoisting + Temporal Dead Zone

let und const werden ebenfalls gehoisted — die Engine kennt ihren Namen ab Block-Beginn. Der Unterschied zu var: sie sind in dieser Phase uninitialized, nicht undefined. Jeder Zugriff in der Phase zwischen Block-Start und Deklaration wirft ReferenceError. Dieser Bereich heißt Temporal Dead Zone.

JavaScript tdz-erklaert.js
{
    // Block-Start — TDZ beginnt hier
    // console.log(x); // ReferenceError: Cannot access 'x' before initialization
    // typeof x;       // ReferenceError — auch typeof greift in TDZ

    let x = 1;          // TDZ endet — x ist initialisiert
    console.log(x);     // 1
}

Wichtig: die TDZ beginnt am Block-Anfang, nicht erst eine Zeile vor der Deklaration. Eine Variable mit gleichem Namen aus einem äußeren Scope wird im inneren Block durch das let/const „verdunkelt" — sogar in den Zeilen davor.

JavaScript tdz-shadowing.js
const x = 'aussen';
{
    // console.log(x); // ReferenceError — innerer x "taintet" den ganzen Block,
                      //                obwohl die Deklaration erst unten kommt
    const x = 'innen';
    console.log(x);   // 'innen'
}

Function-Expression vs. Function-Declaration

Eine Function-Expression — also eine Funktion, die einer Variable per const, let oder var zugewiesen wird — folgt den Hoisting-Regeln der jeweiligen Variable, nicht denen einer normalen Function-Declaration. Das ist eine der häufigsten Verwechslungen bei Anfängern.

JavaScript expression-vs-declaration.js
// Function-Declaration — voll gehoisted
sagHallo();         // 'Hallo!'
function sagHallo() { console.log('Hallo!'); }

// Function-Expression mit const — TDZ wie const
// sagWelt();       // ReferenceError: Cannot access 'sagWelt' before initialization
const sagWelt = function() { console.log('Welt!'); };
sagWelt();          // 'Welt!'

// Arrow-Function mit const — gleiches Verhalten
// sagBye();        // ReferenceError
const sagBye = () => console.log('Bye!');
sagBye();           // 'Bye!'
Output
Hallo!
Welt!
Bye!

Die Konsequenz: wer Funktionen mit const f = () => ... definiert (modern empfohlene Form), kann sie nicht „vor ihrer Zeile" aufrufen. Das ist meist gewollt — Code wird linear lesbar.

Class-Hoisting — TDZ wie let

Klassen werden ebenfalls gehoisted, fallen aber in dieselbe Kategorie wie let und const: existent ab Block-Beginn, aber in TDZ. Ein Aufruf vor der Class-Zeile wirft ReferenceError.

JavaScript class-hoisting.js
// const a = new Auto(); // ReferenceError: Cannot access 'Auto' before initialization

class Auto {
    constructor(marke) { this.marke = marke; }
}

const b = new Auto('VW'); // ok
console.log(b.marke);     // 'VW'

Das gilt auch für die Erweiterung anderer Klassen: class Sub extends Super wirft, wenn Super zur Zeile noch in TDZ ist.

Praxis: Engine-Phasen — Creation und Execution

Das mentale Modell, das hinter Hoisting steht, lässt sich gut als zwei Pseudo-Phasen einer Funktion lesen. Nehmen wir folgenden Code:

JavaScript phasen-quelle.js
function demo() {
    console.log(a, b, c);
    var a = 1;
    let b = 2;
    const c = 3;
    inner();
    function inner() { console.log('inner'); }
}

So liest die Engine das beim Aufruf von demo() — zuerst die Creation Phase, dann die Execution Phase:

JavaScript phasen-pseudo.js
// === Creation Phase ===
// - var a       : Slot anlegen, mit undefined initialisieren
// - let b       : Slot anlegen, status: uninitialized (TDZ)
// - const c     : Slot anlegen, status: uninitialized (TDZ)
// - function inner : Slot anlegen UND Body zuweisen

// === Execution Phase ===
// console.log(a, b, c)
//    -> a = undefined ok
//    -> b ReferenceError — Funktion bricht hier ab
//
// (Der Rest wird gar nicht erreicht.)

Diese Trennung ist der Schlüssel zum Verstehen aller Hoisting-Effekte. Was in der Creation Phase passiert, bestimmt, was in der Execution Phase erlaubt ist.

Hoisting-Reihenfolge bei Konflikten

Wenn im selben Scope eine Function-Declaration und ein var mit dem gleichen Namen stehen, gewinnt am Ende der Creation Phase die Function — der Slot zeigt auf den Funktions-Body. Während der Execution Phase kann eine var-Zuweisung dann den Slot wieder mit einem anderen Wert überschreiben.

JavaScript hoisting-konflikt.js
console.log(typeof foo); // 'function' — function gewinnt das Hoisting

var foo = 'eine String';

function foo() { return 42; }

console.log(typeof foo); // 'string' — var-Zuweisung hat überschrieben
Output
function
string

Mit let oder const an dieser Stelle wäre der Code ein SyntaxError beim Parsen — Re-Deklaration verboten. Genau deshalb sind diese modernen Formen einfacher zu durchschauen.

Anti-Patterns durch Hoisting

Hoisting ist die Wurzel mehrerer klassischer Bug-Muster. Drei davon sind besonders verbreitet:

  1. var in einem if-Block, der außerhalb des Blocks weiterlebt — ein Klassiker beim Refactoring.
  2. Function-Declarations in Blöcken, deren Verhalten zwischen Strict und Sloppy Mode unterschiedlich ist.
  3. Variable, die vor der Zeile verwendet wird, an der sie deklariert wird — bei var ein versteckter undefined, bei let ein klarer ReferenceError.
JavaScript anti-patterns.js
// Anti-Pattern 1: var lebt außerhalb des if-Blocks
function f1() {
    if (true) { var x = 1; }
    console.log(x); // 1 — überraschend
}

// Anti-Pattern 2: function-Declaration in if-Block
// (in strict mode block-scoped, in sloppy mode hochgehoben)
function f2(flag) {
    if (flag) {
        function helper() { return 'ja'; }
    }
    // helper() — Verhalten je nach Modus inkonsistent
}

// Anti-Pattern 3: var-Hoisting verwischt Lesbarkeit
function f3() {
    console.log(name); // undefined — wirkt wie ein Bug
    var name = 'Anna';
}

In modernem Code, der konsequent let/const und ESM nutzt, verschwindet das meiste davon — die Engine zwingt zur sauberen Reihenfolge.

Best Practices

Drei Regeln decken praktisch alle Fälle gut ab:

  • Deklarationen am Anfang des Scopes platzieren. Auch wenn Hoisting es technisch erlaubt, weiter unten zu deklarieren — Lesbarkeit gewinnt, wenn die Reihenfolge der physischen Zeilen mit der logischen Reihenfolge übereinstimmt.
  • function-Declarations nicht in Blöcken nutzen. Stattdessen eine Variable mit Function-Expression: const helper = () => .... Das vermeidet die Mode-Unterschiede zwischen Strict und Sloppy.
  • let/const statt var. Die TDZ macht Hoisting-Bugs zu lauten Fehlern, statt sie als undefined-Werte zu verstecken.
DeklarationHoisted?Initialisiert mitZugriff vor Zeile
function name(){}jaBodyerlaubt
var xjaundefinedliefert undefined
let xjauninitializedReferenceError (TDZ)
const xjauninitializedReferenceError (TDZ)
class C {}jauninitializedReferenceError (TDZ)
import (statisch)jagebunden zur Linkzeiterlaubt (nach Linking)

Typische Fallen

typeof undeclared ist 'undefined' — typeof in TDZ wirft

typeof unbekannt liefert 'undefined' ohne Fehler — eine alte Konvention für Feature-Detection (typeof window etc.). Aber: typeof x innerhalb der TDZ einer let/const-Variablen wirft ReferenceError. Das überrascht regelmäßig: ein Identifier, der existiert, fühlt sich „weniger sicher" an als einer, der nicht existiert.

function-Declarations in if-Blöcken sind in strict verboten

Im Sloppy Mode werden sie inkonsistent zwischen Engines behandelt — manche hoisten in den Function-Scope, andere block-scopen sie. Strict mode definiert sie streng als block-scoped. Empfehlung: in Blöcken keine function name() {...} verwenden, stattdessen const name = () => ....

TDZ besteht ab Block-Anfang, NICHT ab let-Statement

Die TDZ einer let/const-Variable beginnt mit der schließenden geschweiften Klammer des umgebenden Blocks — also vom Block-Anfang an, nicht erst eine Zeile davor. Effekt: ein äußeres x wird im inneren Block schon „verdunkelt", obwohl die innere Deklaration erst weiter unten steht. Zugriff dazwischen wirft ReferenceError.

var-Hoisting greift auch in for-Schleifen-Initializern

for (var i = 0; i < 5; i++) { ... } macht i nach der Schleife im Function-Scope sichtbar, mit Wert 5. Mit let ist i auf den for-Block beschränkt und nach der Schleife nicht zugänglich. Klassischer Bug-Vektor in alten Codebasen.

Klassische Interview-Frage: var vs. console.log davor

console.log(x); var x = 5; gibt undefined aus — nicht ReferenceError, weil var bereits in der Creation Phase mit undefined initialisiert wurde. Mit let wäre das ein Fehler. Kandidaten, die diese Differenz nicht kennen, fallen in fast jedem JS-Interview hier durch.

function-Declarations werden compile-time analysiert

Daher kann eine Funktion sich selbst rekursiv aufrufen, ohne dass je eine TDZ entsteht. Auch wechselseitige Rekursion zwischen mehreren function f() {}-Definitionen funktioniert, egal in welcher Reihenfolge sie geschrieben sind. Bei const f = () => ...-Definitionen geht das nur, wenn die Aufrufe innerhalb der Funktions-Bodies passieren — nicht in der Initialisierung.

import-Statements werden gehoisted und bei Linking aufgelöst

Statische import-Anweisungen sind „voll gehoisted" — sie werden zur Linking-Phase aufgelöst, bevor irgendein Code des Moduls läuft. Daher kann der Quelltext ein import auch erst weiter unten platzieren. Üblich ist aber das Konvention, sie alle oben zu sammeln. Dynamisches import() ist davon nicht betroffen — das ist ein gewöhnlicher Funktions-Aufruf.

let f = function() {} hat TDZ — der Hoisting-Trick funktioniert nicht

let f = function() {} oder const f = () => ... haben TDZ wie jede let/const-Variable. Der klassische Trick „Funktion vor Definition aufrufen" funktioniert NUR mit function name() {}-Declarations. In modernem Code ist das meist kein Problem, weil Code-Reihenfolge ohnehin top-down geschrieben wird.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Syntax & Sprachkern

Zur Übersicht