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