let else ist eines der wichtigsten Sprach-Updates seit langem (stable seit Rust 1.65, November 2022). Es löst ein hartnäckiges Problem: Funktionen mit mehreren Validierungs-Schritten erzeugen ohne let else entweder tiefe Indent-Pyramiden oder verbose match-Statements. Mit let else wird daraus linearer Code — jeder Schritt validiert, im Fehlerfall verlässt man die Funktion sofort, im Erfolgsfall ist das gebundene Pattern direkt im umliegenden Scope nutzbar. Dieser Artikel zeigt die Syntax, die Regeln (Pattern muss refutable sein, else muss divergent enden) und die zentralen Anwendungs-Patterns.

Hinweis zu &'static str: Viele Validierungs-Beispiele geben Fehler als &'static str zurück — also eine Referenz auf einen Text, der für die gesamte Programm-Laufzeit gültig ist (klassisch ein Fehler-Literal aus dem Code). Die 'static-Notation ist eine Lifetime und wird im Lifetimes-Kapitel ausführlich erklärt; hier reicht das mentale Bild „Verweis auf einen festen Fehler-Text".

Das Problem

Klassischer Validierungs-Code vor let else:

Rust Verschachtelt — vor let else
# struct User;
# fn datenbank_lookup(_: u64) -> Option<User> { None }
fn lade_user(id_str: &str) -> Result<User, &'static str> {
    let id: u64 = match id_str.parse() {
        Ok(n) => n,
        Err(_) => return Err("ID keine Zahl"),
    };

    let user = match datenbank_lookup(id) {
        Some(u) => u,
        None => return Err("User nicht gefunden"),
    };

    Ok(user)
}

Funktioniert, ist aber verbose. Bei mehreren Schritten wird's noch unübersichtlicher.

Mit let else:

Rust Linear — mit let else
# struct User;
# fn datenbank_lookup(_: u64) -> Option<User> { None }
fn lade_user(id_str: &str) -> Result<User, &'static str> {
    let Ok(id) = id_str.parse::<u64>() else {
        return Err("ID keine Zahl");
    };

    let Some(user) = datenbank_lookup(id) else {
        return Err("User nicht gefunden");
    };

    // user ist hier direkt verfügbar — kein Indent, kein Hilfs-match
    Ok(user)
}

Der Unterschied ist dramatisch. Statt eines match mit zwei Armen pro Schritt (Ok für den Erfolg, Err für den Frühabbruch) ist let else eine einzige Zeile pro Schritt. Die ID wird in id gebunden und steht direkt im äußeren Scope zur Verfügung — kein let id = ... mit Pattern-Match darin, kein Hilfs-Match.

Bei zwei Schritten wirkt der Unterschied klein, bei fünf oder zehn aufeinander folgenden Validierungen wird er enorm. Code, der mit let else linear nach unten läuft, wäre ohne den Operator entweder eine tiefe Pyramide aus verschachtelten match-Statements oder eine Reihe von verbose match/Err return-Blöcken. let else ist seit Rust 1.65 stable und gilt unter erfahrenen Rust-Entwicklern als einer der wichtigsten Sprach-Quality-of-Life-Updates überhaupt.

Die Syntax

Rust Syntax
// let pattern = expression else { divergent_block };

let Some(n) = optional else {
    return;
};
// n ist hier verfügbar.
# let optional: Option<i32> = None;

Drei Bestandteile prägen die Syntax:

let pattern = expression — wie bei normalem let, aber mit refutable Pattern (siehe unten). Der Compiler versucht, das Pattern auf den Wert anzuwenden.

else { ... } — wird ausgeführt, wenn das Pattern nicht matcht. Bei let Some(n) = optional else { ... } läuft der else-Block für den None-Fall.

Der else-Block muss divergent sein — er darf den Funktions-Scope nicht regulär verlassen. Erlaubte Konstrukte: return, break, continue, panic!, loop {}, oder jeder andere Ausdruck mit Typ ! (never-Type). Der Compiler verlangt das, weil andernfalls die Pattern-Bindung im äußeren Scope ungültig wäre.

Im Erfolgsfall ist das gebundene Pattern im äußeren Scope verfügbar — und das ist der entscheidende Unterschied zu if let, wo die Bindung nur im if-Body lebt. Bei let else wird sie zur normalen let-Bindung; im Fehlerfall ist die Funktion (oder Schleife) schon verlassen, also gibt es kein „nach else"-Problem.

Regeln im Detail

Pattern muss refutable sein

let else macht nur Sinn mit Patterns, die fehlschlagen können. Sonst ist der else-Branch toter Code.

Rust Pattern-Refutability
# let x: i32 = 42;
// Refutable — kann fehlschlagen:
let Some(n) = Some(42) else { return; };
let Ok(m) = Ok::<i32, ()>(42) else { return; };

// Infallible — Compile-Fehler:
// let (a, b) = (1, 2) else { return; };
// Lösung: einfaches let
let (a, b) = (1, 2);

Patterns lassen sich in zwei Kategorien einteilen: refutable (können fehlschlagen, z. B. Some(x) auf einem Option) und infallible (können nie fehlschlagen, z. B. ein Tupel-Pattern auf einem Tupel oder eine Variable). let else ist nur sinnvoll für refutable Patterns — sonst gäbe es nichts, was den else-Branch jemals auslösen würde.

Bei infallible Patterns (Tupel-Destrukturierung, Struct ohne Enum-Variante, Variable) lehnt der Compiler die Syntax ab. Lösung: normales let ohne else verwenden. Das ist nicht restriktiv — es ist nur Konsequenz: wenn das Pattern immer matcht, ist der else-Block toter Code.

Else-Block muss divergent sein

Rust Divergent Else
fn beispiel(x: Option<i32>) -> i32 {
    // ok — return ist divergent
    let Some(n) = x else { return -1; };

    // ok — panic ist divergent
    // let Some(n) = x else { panic!("Wert fehlt"); };

    // ok — continue/break in einer Schleife
    // let Some(n) = x else { continue; };

    // NICHT ok — der Block gibt einen normalen Wert zurück
    // let Some(n) = x else { 0 };

    n
}

Der else-Block muss den Funktions-Scope verlassen oder in einer Schleife continue/break machen. Ein normaler Wert ist Compile-Fehler.

Bindings im äußeren Scope

Rust Scope
fn beispiel(x: Option<i32>) -> i32 {
    let Some(n) = x else { return -1; };
    // n ist HIER — im äußeren Scope — verfügbar.
    n * 2
}

Das ist die zentrale Eigenschaft von let else — und genau der Grund, warum es als Game-Changer gilt. Bei if let Some(n) = ... ist n nur im Body des if sichtbar; sobald du den if-Block verlässt, ist die Bindung weg. Bei let else ist n dagegen im gesamten umliegenden Scope verfügbar, genau wie bei einem normalen let.

Das hat eine wichtige Praxis-Konsequenz: nach let else kannst du linearen Code schreiben, der mit der gebundenen Variable arbeitet — keine Verschachtelung, keine Indent-Anstiege. Vorher musste man oft umständliche Workarounds nutzen (etwa explizites match plus let var = match ...-Pattern), um an die Bindung im äußeren Scope zu kommen.

Vergleich zu Alternativen

PatternVerhalten bei FehlerBindung im äußeren Scope
matchbeliebig im jeweiligen Armnein
if let / elsebeliebig im else-Branchnein (nur im if-Body)
let elsemuss divergent endenja
?-Operatorearly return mit Err/Noneja, aber nur für Result/Option

let else füllt eine Lücke im Pattern-Matching-Spektrum. Es gibt mehrere Alternativen für ähnliche Aufgaben, jede mit ihrer eigenen Charakteristik. Die Wahl hängt davon ab, was du mit dem extrahierten Wert tun willst.

Wenn du den Wert nach dem Pattern-Match im linearen Code weiterverwenden willst und der Fehlerfall einen frühen Ausstieg bedeutet, ist let else die richtige Wahl. Wenn du auf mehrere Patterns verzweigen willst, ist match besser. Wenn du nur einen Zweig brauchst und keinen Wert für später, reicht if let. Wenn du innerhalb einer Funktion mit Result-Rückgabe nur Fehler durchreichen willst, ist ? am kompaktesten.

Mehrere let else hintereinander

Der eigentliche Wert von let else zeigt sich, wenn mehrere Validierungs-Schritte aufeinander folgen:

Rust Multi-Step
use std::env;

#[derive(Debug)]
struct Config {
    host: String,
    port: u16,
    workers: u32,
}

fn lade_config() -> Result<Config, String> {
    let Ok(host) = env::var("APP_HOST") else {
        return Err("APP_HOST fehlt".into());
    };
    let Ok(port_str) = env::var("APP_PORT") else {
        return Err("APP_PORT fehlt".into());
    };
    let Ok(port) = port_str.parse::<u16>() else {
        return Err(format!("APP_PORT '{port_str}' keine u16"));
    };
    let workers: u32 = env::var("APP_WORKERS")
        .unwrap_or("4".into())
        .parse()
        .map_err(|_| "APP_WORKERS keine u32")?;

    Ok(Config { host, port, workers })
}

Hier siehst du den Mehrwert von let else in seiner besten Form. Die Config-Lade-Logik hat vier potenziell-fehlbare Schritte: Host aus ENV, Port aus ENV, Port als u16 parsen, Workers parsen. Jeder Schritt ist eine eigene Zeile, jeder kann mit einer aussagekräftigen Fehler-Nachricht abbrechen.

Ohne let else wäre das eine Indent-Pyramide: das erste match hätte sein Ok-Arm einen weiteren match im Body, der wiederum einen, und so weiter. Oder eine Reihe von fünf separaten match-Blöcken mit jeweils return-Statements im Err-Arm. Beides ist deutlich länger und schwerer zu lesen.

Der letzte Schritt (workers) zeigt eine alternative Form mit ?-Operator und map_err — auch valide und kompakt. In der Praxis mischt man oft beide Stile, je nachdem, was im jeweiligen Fall klarer ist.

let else mit Slice-Patterns

let else funktioniert mit allen refutable Patterns — einschließlich Slice-Patterns:

Rust Slice-Pattern
fn process(args: &[String]) -> Result<(), &'static str> {
    let [_program, cmd, arg] = args else {
        return Err("Aufruf: programm <cmd> <arg>");
    };

    match cmd.as_str() {
        "lese" => println!("Lese: {arg}"),
        "schreibe" => println!("Schreibe: {arg}"),
        _ => return Err("unbekanntes Kommando"),
    }
    Ok(())
}

let [_program, cmd, arg] = args else — matcht nur, wenn args exakt 3 Elemente hat. Sehr typisches CLI-Pattern.

let else mit komplexen Patterns

Rust Verschachtelt
struct Account { profil: Option<Profil> }
struct Profil { name: String }

fn extrahiere_name(a: &Account) -> Option<&str> {
    let Some(Profil { name, .. }) = &a.profil else {
        return None;
    };
    Some(name.as_str())
}

Verschachtelte Patterns mit let else — auch komplex destrukturieren in einem Schritt.

Praxis: let else im echten Code

Config-Laden mit voller Validierung

Rust Config
pub fn lade_aus_env() -> Result<u16, String> {
    let Ok(s) = std::env::var("PORT") else {
        return Err("PORT nicht gesetzt".into());
    };
    let Ok(port) = s.parse::<u16>() else {
        return Err(format!("PORT '{s}' keine u16"));
    };
    if port < 1024 {
        return Err("Port < 1024 ist privilegiert".into());
    }
    Ok(port)
}

Drei Validierungs-Schritte. Jeder hat eine eigene Fehler-Meldung mit Kontext.

CLI-Argument-Validator

Rust CLI
pub fn parse_args(args: &[String]) -> Result<(String, String), String> {
    let [_program, cmd, arg] = args else {
        return Err("Genau 2 Argumente: <cmd> <arg>".into());
    };
    if cmd.is_empty() {
        return Err("Kommando darf nicht leer sein".into());
    }
    Ok((cmd.clone(), arg.clone()))
}

Slice-Pattern für Argument-Anzahl, weitere Validierung mit klassischen Checks.

Skip-on-Error in Schleife

Rust Iterator-Filter
pub fn verarbeite_zeilen(zeilen: &[String]) {
    for zeile in zeilen {
        let Ok(n) = zeile.parse::<i32>() else {
            eprintln!("Übersprungen: '{zeile}'");
            continue;
        };
        println!("Verarbeite: {n}");
    }
}

continue als divergenter Branch — überspringt den Rest der Schleifen-Iteration.

Option-Field-Extraction

Rust Optional-Field
struct User { email: Option<String> }

pub fn domain_extrahieren(u: &User) -> Result<&str, &'static str> {
    let Some(email) = &u.email else {
        return Err("Email fehlt");
    };
    let Some(at_pos) = email.find('@') else {
        return Err("Email hat kein @");
    };
    Ok(&email[at_pos + 1..])
}

Drei Validierungs-Schritte, alle linear.

Composite Pattern Match

Rust Verschachtelt destrukturiert
enum Response {
    Ok { body: Vec<u8>, content_type: String },
    Err(String),
}

pub fn extrahiere_text(r: Response) -> Result<String, String> {
    let Response::Ok { body, content_type } = r else {
        return Err("Response war Err".into());
    };
    if !content_type.starts_with("text/") {
        return Err(format!("Kein Text-Type: {content_type}"));
    }
    String::from_utf8(body).map_err(|e| e.to_string())
}

Enum-Destrukturierung mit let else — sehr lesbar, alle Felder im äußeren Scope verfügbar.

Combined mit ?

Rust Mit ?-Operator
pub fn handle_request(input: &str) -> Result<i32, &'static str> {
    // let else für „braucht eine bestimmte Form"
    let Some((key, wert)) = input.split_once('=') else {
        return Err("kein = gefunden");
    };

    // ? für Standard-Conversion
    let zahl: i32 = wert.parse().map_err(|_| "kein i32")?;

    // weitere Logik
    Ok(zahl * key.len() as i32)
}

Kombination aus let else (für Form-Validierung) und ? (für Type-Conversion). Beides linear, kein Indent.

State-Validierung

Rust State-Check
enum Verbindung { Inaktiv, Aktiv(String), Geschlossen }

pub fn sende(v: &Verbindung, msg: &str) -> Result<(), &'static str> {
    let Verbindung::Aktiv(peer) = v else {
        return Err("Nicht im aktiven Zustand");
    };
    println!("Sende '{msg}' an {peer}");
    Ok(())
}

State-Validation mit Enum-Pattern — Send nur im Aktiv-State erlaubt.

Mehrfaches Lookup

Rust Multi-Lookup
use std::collections::HashMap;

pub fn baue_url(config: &HashMap<String, String>) -> Result<String, &'static str> {
    let Some(host) = config.get("host") else {
        return Err("host fehlt");
    };
    let Some(scheme) = config.get("scheme") else {
        return Err("scheme fehlt");
    };
    let port = config.get("port").map(|s| s.as_str()).unwrap_or("80");
    Ok(format!("{scheme}://{host}:{port}"))
}

Zwei Pflicht-Lookups, ein optionaler — Mix aus let else (Pflicht) und unwrap_or (Default).

Mit Block-Label

Rust Block-Label
pub fn klassifiziere(input: &str) -> &'static str {
    'bestimmen: {
        let Some(first) = input.chars().next() else {
            break 'bestimmen "leer";
        };
        if first.is_ascii_digit() {
            break 'bestimmen "fängt mit Ziffer an";
        }
        "fängt mit Buchstabe an"
    }
}

let else mit break 'label. Block-Labels (seit Rust 1.65) als divergenter Ziel — perfekte Ergänzung zu let else.

Besonderheiten

let else ist Edition-übergreifend stable (seit Rust 1.65).

Eine der wichtigsten Sprach-Erweiterungen der letzten Jahre. Macht aus tiefen Indent-Pyramiden lineare Validierungs-Pipelines.

Else-Block MUSS divergent enden.

return, break, continue, panic!, todo!(), unimplemented!(), loop {} — alles mit Typ !. Ein normaler Wert oder () ist Compile-Fehler.

Bindings landen im äußeren Scope.

Anders als if let, bei dem die Bindung nur im Body lebt. Bei let else ist die im Pattern gebundene Variable im gesamten Funktions-/Block-Scope verfügbar. Das ist der Hauptvorteil.

Mehrere let else ergeben lineare Validierung.

Vier let else hintereinander = vier Validierungs-Schritte mit klarer Fehler-Diagnose pro Fall. Ohne let else wären das vier verschachtelte oder verbose match-Blöcke.

let else ist nicht der ?-Ersatz.

? automatisiert Result/Option-Propagation. let else ist allgemeiner — funktioniert auf jedem refutable Pattern, du schreibst den Fehler-Pfad selbst. Beide ergänzen sich.

Slice-Patterns in let else sind extrem nützlich für CLI.

let [_program, cmd, arg] = args else { return Err("..."); }; — exakte Argument-Anzahl plus Validierung in einem Schritt.

Mit Block-Labels für lokalen „Exit".

Innerhalb eines 'label: { ... }-Blocks kannst du let else mit break 'label value kombinieren — lokaler Exit ohne Funktion zu verlassen.

Komplexe Patterns funktionieren auch.

let Some(Profil { name, .. }) = &a.profil else { return None; }; — verschachtelte Destrukturierung in einem Schritt. Sehr expressiv.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Enums & Pattern Matching

Zur Übersicht