Antra

Fehlerbehandlung

Fehlerstile, Result-APIs und wann was passt

Dieses Kapitel beantwortet eine praktische Frage, die in Antra schnell auftaucht:

  • Wann sollte eine Funktion %Ok[...] oder %Err[...] liefern?
  • Wann ist match die klarste Form?
  • Wann passt eine Header-Bindung mit if val ...?
  • Und wann sind throw, die(...) oder error(...) wirklich die bessere Wahl?

Die kurze Antwort ist:

  • fuer normale Bibliotheks-APIs meist %Ok[...] und %Err[...]
  • fuer Form-unterscheidung meist match
  • fuer "binde erst etwas und pruefe dann eine normale Bedingung" oft if val ...
  • fuer Exception-Grenzen throw, die(...) und try/catch

Die wichtigste Trennung#

In Antra gibt es zwei grobe Fehlerstile:

  1. Rueckgabewerte, die Erfolg oder Fehlschlag ausdruecken
  2. Exceptions, die den normalen Kontrollfluss verlassen

Die Rueckgabewert-Seite ist meist:

  • %Ok[...]
  • %Err[...]
  • manchmal auch einfache undef- oder Paar-Konventionen fuer kleine interne Helfer

Die Exception-Seite ist meist:

  • throw value
  • die(...)
  • error(...) als strukturierter Fehlerwert
  • try/catch zum Abfangen

Fuer Enduser und Bibliotheken ist eine Regel fast immer gut:

Wenn ein Aufrufer sinnvoll weiterentscheiden kann, ist ein Rueckgabewert meist besser als sofortiges Werfen.

Default fuer Bibliotheken: %Ok[...] und %Err[...]#

Wenn ein Fehlschlag zum normalen Verhalten der API gehoert, ist Result fast immer die beste Surface.

import Antra::Regex as Regex

fn parse_port(text) -> {
    unless (Regex.test(text, "^[0-9]+$")) {
        %Ok[int(text)]
    }
}

Das passt besonders gut fuer:

  • Parser
  • Validierung
  • Dateizugriffe
  • Netzwerk- und Prozess-Wrapper

Der Vorteil ist nicht nur technischer Natur. Die API liest sich klarer:

Wichtig dabei: %Err[...] ist in Antra wert-allgemein. Viele APIs nutzen Strings wie %Err["not found"], aber genauso moeglich sind strukturierte Werte wie %Err[error("ungueltige Konfiguration")].

Fuer Enduser sollte das aber nicht als "alles ist beliebig" gelesen werden. Die praktikable Leseregel ist:

  • bei kleinen Collection- oder Helper-APIs oft %Err["..."]
  • bei Boundary-Modulen wie Bytes, Antra::IO::File, Antra::FS, Antra::Process, Antra::System sowie Format-/Decode-APIs wie HTML.parse(...), JSON.parse(...) oder AND.encode/decode(...) bewusst %Err[err] und dann err.message
  • innerhalb eines Moduls moeglichst nicht mischen

Die ausfuehrlichere Policy dazu steht in ../specs/spec_result_error_policy.md.

Das hilft auch dem Aufrufer. Er sieht direkt am Rueckgabewert, dass hier keine stille Ausnahme versteckt ist.

Warum Result oft besser ist als [value, err]#

Manche APIs liefern Paare wie [value, err] oder [result, err]. Das kann funktionieren, besonders in kleinen internen Helfern oder bei Interop-Stil.

Fuer normale User- und Bibliotheks-APIs sollte das aber nicht die bevorzugte Surface sein. In der Praxis sind %Ok[...] und %Err[...] fast immer robuster und klarer lesbar. sein, wenn Native- oder Interop-Schichten bewusst einfacher bleiben sollen:

  • foo_raw(...) liefert ein rohes Paar wie [value, undef] oder [undef, error("...")]
  • foo(...) hebt diese Form in %Ok[...] | %Err[...] an
  • foo(...).! ist die strenge fail-fast-Form: entpackt oder wirft

Die detailliertere Konvention dazu steht in ../specs/spec_result_error_policy.md.

Wer normale Antra-APIs entwirft oder konsumiert, sollte typischerweise mit Result, ? und match arbeiten. try/catch passt vor allem zu strengen ...!-Surfaces, nicht zu rohen Fehlerpaaren.

Wenn man bewusst an so einer Boundary arbeitet, kann eine Header-Bindung mit normalem Guard aber gut passen:

fn returns_undef_with_msg() -> {
    my err = error("Error Message")
    [undef, err]
}

if val [result, err] = returns_undef_with_msg(); err != undef {
    dd(err)
    println(err.message)
}

Ausserhalb von fn-Bodies oder passenden Callbacks endet nacktes Top-Level-%Err[...].^ jetzt still den restlichen Skriptlauf. Wenn der Abbruch nur lokal fuer einen Teilbereich gelten soll, kann man den Code bewusst in ein do { ... } setzen; ein %Err[...].^ beendet dann nur diesen Block, und der Code danach laeuft weiter.

Das ist hier als _raw-/Interop-Stil gemeint, nicht als bevorzugte allgemeine Fehler-Surface fuer normale Bibliotheks-APIs.

Der Hauptgrund ist: [value, err] kann unklare Zustaende ausdruecken.

  • Was bedeutet [undef, undef]?
  • Was bedeutet [value, err], wenn beide gesetzt sind?
  • Muss man immer wissen, welcher Slot Erfolg und welcher Fehler ist?

Bei %Ok[...] und %Err[...] ist die Form dagegen eindeutig:

fn load_port(text) -> {
    if text == "" {
        %Err[error("Port fehlt")]
    } else {
        %Ok[int(text)]
    }
}

Der Aufrufer sieht dann sofort, womit er arbeitet:

match load_port(raw_port) {
    %Ok[port] -> println("Port: " <> str(port))
    %Err[err] -> println(err.message)
}

Wenn du also eine neue API entwirfst, ist %Ok[...]/%Err[...] meist die klarere erste Wahl. Das Paar-Schema bleibt eher etwas fuer kleine lokale Helfer, Migrationen oder sehr einfache interne Rueckgaben.

Wann match die beste Form ist#

match ist in Antra die klarste Form, wenn die Form des Werts selbst die Verzweigung bestimmt.

Das gilt besonders fuer:

  • %Ok[...] gegen %Err[...]
  • Tagged Arrays
  • Tagged Maps
  • mehrere fachliche Varianten eines Rueckgabewerts
match parse_port(text) {
    %Ok[port] -> connect(port)
    %Err[msg] -> println("Ungueltige Eingabe: " <> msg)
}

Hier ist match besser als ein normales if, weil nicht eine Bool-Bedingung, sondern die Datenform den Branch waehlt.

Fuer den kleineren lokalen Zweiweg-Fall gibt es jetzt auch pattern-if:

if %Ok[port] <- parse_port(text) {
    connect(port)
} else %Err[err] {
    println(err.message)
}

Das ist gut, wenn du genau diesen einen Result-Fall lokal auseinanderziehen willst. Fuer drei und mehr sichtbare Varianten oder fuer bewusst exhaustive Formen bleibt match lesbarer.

Auch bei strukturierten Fehlern ist match oder ein catch mit Pattern oft am lesbarsten:

try {
    throw error("ungueltige Konfiguration", [site: :caller])
} catch {
    %Error{message: msg, site: :caller, ..} -> println(msg)
}

Wann if val ... gut passt#

Die Header-Bindung mit if val ...; ... { ... } ist staerker fuer diesen Stil gedacht:

  • binde etwas lokal
  • pruefe danach eine normale Bedingung
  • nutze die gebundenen Namen im Branch

Zum Beispiel:

if val user = find_user(id); defined? user {
    println(user.name)
} else {
    println("not found")
}

Das ist gut, wenn die eigentliche Entscheidung eine normale Ja/Nein-Frage ist:

  • defined? user
  • err != undef
  • size(items) > 0
  • status == :ok

Dein Beispiel mit [result, err] passt genau in diese Ecke:

if val [result, err] = returns_undef_with_msg(); err != undef {
    dd(err)
    println(err.message)
}

Das ist voellig legitim, wenn die Funktion bewusst ein Paar liefert. Fuer neue API-Oberflaechen ist ein match auf %Ok[...]/%Err[...] aber meist klarer, weil der Branch dann direkt an der Rueckgabeform haengt und nicht an einer zweiten Konvention wie err != undef.

Faustregel:

  • match, wenn du Formen unterscheidest
  • if pattern <- expr, wenn du einen kleinen lokalen Pattern-Zweiweg-Fall lesen willst
  • if val, wenn du bindest und danach eine normale Bedingung pruefst

Wann use und Result.and_then(...) besser sind#

Wenn du mehrere Result-Schritte linear hintereinander ausfuehrst, wird match schnell zu verschachtelt. Dann ist use oft die ruhigere Form.

import Antra::Option as Option

fn first_or_err(xs, message) -> Option.ok_or(head(xs), message)

fn first_rollout_target(raw_json) -> {
    use plan <- Result.and_then(JSON.parse(raw_json))
    use stage <- Result.and_then(first_or_err(plan.stages, "plan has no stages"))
    use target <- Result.and_then(first_or_err(stage.targets, "stage has no targets"))
    %Ok[target]
}

Das ist gut fuer Pipelines, in denen jeder Schritt wieder %Ok[...] oder %Err[...] liefert.

Daneben gibt es auch die kompaktere Result-Propagation ueber postfix ?:

import Antra::Option as Option

fn first_or_err(xs, message) -> Option.ok_or(head(xs), message)

fn first_rollout_target(raw_json) -> {
    val plan = JSON.parse(raw_json).^ 
    val stage = first_or_err(plan.stages, "plan has no stages").^
    val target = first_or_err(stage.targets, "stage has no targets").^
    %Ok[target]
}

use bleibt dabei unveraendert. Die Frage ist also nicht "alt oder neu", sondern eher:

  • use plus Result.and_then(...) fuer den expliziteren Fortsetzungsstil
  • .^ fuer den kuerzeren linearen Happy Path

Was .^ dabei wirklich macht#

Fuer Error-Handling ist wichtig: postfix .^ ist nicht nur ein spezieller Result-Trick, sondern die Oberflaeche eines kleinen Protocols.

Konzeptionell fragt expr.^ den Wert links vom .^:

  • kannst du einen Erfolgswert zum Weiterarbeiten liefern?
  • oder musst du den aktuellen Kontext frueh mit einem Abbruchwert verlassen?

Die zugrundeliegende Protocol-Idee ist:

protocol Propagate {
    fn __propagate__(value)
}

Die Rueckgabe von __propagate__(...) folgt einer kleinen Konvention:

  • %Continue[x] bedeutet: mit x normal weitermachen
  • %Break[value] bedeutet: aktuellen fn- oder Callback-Kontext frueh mit value verlassen

Fuer Result ist die Lesart also grob:

  • %Ok[x].^ wird zu x
  • %Err[e].^ verlaesst die Funktion frueh mit %Err[e]

Ein kleines Kontrastpaar macht das oft klarer:

fn inc_result(result) -> {
    val x = result.^
    %Ok[x + 1]
}

Hier ist result bereits ein %Ok[...]- oder %Err[...]-Wert.

Wenn du dagegen erst einen nackten Wert in Result heben willst, passiert das bewusst vor dem .^:

fn lift_ok(x) -> %Ok[x]

fn inc_plain(x) -> {
    val y = lift_ok(x).^
    %Ok[y + 1]
}

Fuer Option entsprechend:

  • %Some[x].^ wird zu x

Daneben gibt es jetzt bewusst eine zweite, weichere Form:

  • %Some[x].? wird zu x
  • %None[].? wird zu undef

Die Trennung ist also:

  • .^ ist hart und kontrollfluss-orientiert
  • .? ist lokal und wert-orientiert
  • %None[].^ verlaesst die Funktion frueh mit %None[]

Darum passt .^ zwar sehr gut zu Error-Handling, ist aber bewusst nicht auf Fehlerwerte beschraenkt. Es funktioniert allgemeiner fuer kurzschlussfaehige Traeger, die ueber Propagate zwischen "weiter" und "hier abbrechen" unterscheiden.

Fuer die Praxis in Antra heisst das:

  • Result ist die normale Wahl fuer APIs, bei denen der Fehler selbst wichtige Information traegt
  • Option passt fuer "Wert vorhanden oder nicht vorhanden"
  • ? ist die gemeinsame lineare Oberflaeche fuer beide Faelle

Wenn du also in diesem Kapitel ? im Error-Handling-Kontext siehst, lies es nicht als Magie fuer Ok/Err, sondern als fruehe Propagation ueber das Propagate-Protocol.

Kurz gesagt:

  • match fuer einen lokalen Verzweigungspunkt
  • use, Result.and_then(...) oder ? fuer lineare Result-Ketten

Hartes Entpacken mit .!#

Result-liefernde Stdlib-Funktionen haben keine eigenen ...!-Zwillinge mehr. Statt einer verdoppelten API entscheidest du an der Call-Site mit dem postfixen .!, ob ein %Err geworfen werden soll:

val bytes = Bytes.from_file(path).!   # %Ok? -> Wert; %Err? -> Throw

.! läuft über das UnwrapOrThrow-Protokoll und verhält sich genau wie das frühere Result.unwrap!:

  • %Err[%Error{...}] wirft den vorhandenen %Error{...} weiter
  • %Err[value] hebt andere Fehlerwerte in einen standardisierten %Error{...}-Throw an

Dadurch bleibt .! für try/catch und %Error{...}-Patterns konsistent, auch wenn die weiche API im Result-Kanal arbeitet.

Praktisch lohnt sich .! vor allem an Boundary-Funktionen, bei denen beide Stile sinnvoll sind:

  • den Result-Kanal für Bibliotheken, Pipelines und lokale Fehlerbehandlung lassen
  • mit .! für Skripte, Tests, REPL und fail-fast-Einstiegspunkte hart entpacken

Typische Beispiele sind Datei-, Prozess-, Netzwerk-, Parse- und Decode-APIs: Bytes.from_file(path) plus .! ist oft genau der richtige Schnitt.

.! funktioniert auch auf Option-artigen Werten, aber %None[] trägt keine Fehlermeldung — dort wirft .! einen generischen Unwrap-Fehler. Für Optionen mit klarer Fehlermeldung bleiben benannte Helfer wie Array.head!/last! sinnvoll, weil sie eine spezifische Meldung erfinden.

Die Rollen bleiben damit bewusst getrennt (siehe auch Kapitel 09):

  • .? liefert lokal undef
  • .^ propagiert den %Err/%None als Wert an die Boundary
  • .! wirft eine Exception

Das Gegenstueck ist Result.try:

  • ein normaler throw "oops" wird zu %Err["oops"]
  • ein strukturierter Bang-Throw wird zu %Err[%Error{...}]

So kannst du bewusst zwischen zwei Ebenen wechseln:

  • im Result-Kanal bleiben
  • oder an einer Boundary mit unwrap! / try in den Exception-Stil wechseln

Wann throw passt#

throw ist gut, wenn du bereits einen Fehlerwert hast und ihn bewusst an einen aeusseren Handler weitergeben willst.

match parse_port(text) {
    %Ok[port] -> connect(port)
    %Err[msg] -> throw msg
}

Das ist oft ein Boundary-Schritt:

  • innen arbeitet eine Bibliothek mit Result
  • aussen moechte ein Skript lieber try/catch verwenden

throw ist also haeufig nicht die erste Wahl fuer die eigentliche Bibliotheks- API, sondern fuer den Uebergang zwischen zwei Fehlerstilen.

Wann error(...) passt#

error(...) baut einen strukturierten %Error{...}-Wert, wirft ihn aber noch nicht.

my err = error("ungueltige Konfiguration", [site: :caller, trace: true])

Das ist sinnvoll, wenn du:

  • mehr als nur eine nackte String-Message brauchst
  • Fehlerdaten loggen willst
  • spaeter praezise auf %Error{...} matchen willst
  • einen Fehlerwert erst bauen und danach entscheiden willst, ob er als %Err[...] oder per throw weitergeht

Beides ist moeglich:

%Err[error("ungueltige Konfiguration")]
throw error("ungueltige Konfiguration")

Genau deshalb ist error(...) eher ein Baustein als eine komplette Fehlerstrategie.

Wann die(...) passt#

die(...) ist die direkte Form von "baue einen strukturierten Fehler und wirf ihn jetzt".

die("Konfiguration fehlt", [site: :caller])

Das passt eher zu:

  • kleinen Skripten
  • CLI-Einstiegspunkten
  • Tests
  • invarianten Zustaenden, die wirklich nicht passieren duerfen
  • strengen ...!-Varianten einer API

Fuer wiederverwendbare Bibliotheken ist die(...) meist nicht die beste Default-Wahl, weil es dem Aufrufer die lokale Entscheidung abnimmt.

Ein gutes Muster ist oft:

  • read_file(...) -> %Ok[...] | %Err[...]
  • read_file!(...) -> value, intern mit die(...) oder throw error(...)

Wann warn(...) und context(...) passen#

warn(...) ist fuer nicht-fatale Hinweise gedacht:

warn("Fallback auf Default-Port", [site: :caller])

Das ist gut fuer:

  • veraltete Pfade
  • Diagnose-Ausgaben
  • riskante Defaults

warn(...) ist aber kein Ersatz fuer %Err[...]. Wenn ein Aufrufer wirklich reagieren koennen soll, ist ein Rueckgabewert meist besser.

context(...) ist die datenorientierte Schwester davon. Es liefert Diagnose- Kontext als normale Map, ohne zu werfen und ohne nach STDERR zu schreiben.

my ctx = context([site: :caller, trace: true])
println(ctx.line)

Das ist gut fuer Logging, Debug-Ausgaben oder strukturierte eigene Fehlerwerte.

Eine einfache Empfehlung fuer Bibliotheken#

Wenn du eine API fuer andere Antra-User entwirfst, ist diese Reihenfolge meist am klarsten:

  1. Normale API mit %Ok[...] und %Err[...]
  2. Optional eine strenge ...!-Variante fuer Skript- oder CLI-Code
  3. %Err[...] gern auch mit strukturierteren Werten wie %Error{...}, wenn der Aufrufer davon profitiert
  4. warn(...) nur fuer Zusatzhinweise, nicht als primaeren Fehlerkanal

Fuer Option gilt dabei bewusst ein strengerer Massstab: ...! nur dann, wenn %None[] fachlich wirklich exceptional ist.

Weniger gut ist meist:

  • normale Bibliotheksfunktionen, die mal %Err[...] liefern und mal einfach werfen
  • APIs, bei denen [value, err] nur implizit verstanden werden muss
  • warn(...) als einziges Fehler-Signal

Kurzfassung#

  • %Ok[...] und %Err[...] sagen: Fehler ist Teil der normalen API
  • match sagt: die Form des Rueckgabewerts entscheidet
  • if val sagt: erst binden, dann eine normale Bedingung pruefen
  • use und Result.and_then(...) sagen: mehrere Result-Schritte werden linear verkettet
  • throw sagt: gib den Fehler an einen aeusseren Handler weiter
  • error(...) sagt: ich brauche einen strukturierten Fehlerwert
  • die(...) sagt: hier ist jetzt wirklich Schluss
  • warn(...) sagt: das ist auffaellig, aber nicht fatal
  • context(...) sagt: gib mir Diagnose-Daten als normalen Wert

Quelle dieser Seite