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.

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)
}

Linearer, kein Indent-Anstieg, jeder Schritt validiert für sich. Das ist der Game-Changer.

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:

  • let pattern = expression — Pattern-Match wie bei normalem let.
  • else { ... } — wird ausgeführt, wenn das Pattern nicht matcht.
  • Der else-Block muss divergent sein — also return, break, continue, panic!, loop {}, oder eine andere Expression mit Typ !.

Im Erfolgsfall ist das Pattern im äußeren Scope gebunden — anders als bei if let, wo die Bindung nur im Body lebt.

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);

Bei infallible Patterns (Tupel, Struct ohne Enum-Variante) gibt's keinen else-Branch zu rechtfertigen.

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 der entscheidende Unterschied zu if let: bei if let Some(n) = ... ist n nur im Body sichtbar. Bei let else ist n im gesamten umliegenden Scope verfügbar.

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: wenn du nach dem Match weitermachen willst und der Fehlerfall einen frühen Ausstieg bedeutet.

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 })
}

Vier Validierungs-Schritte, alle linear. Ohne let else wäre das eine Pyramide oder fünf separate match-Statements.

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