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
matchdie klarste Form? - Wann passt eine Header-Bindung mit
if val ...? - Und wann sind
throw,die(...)odererror(...)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(...)undtry/catch
Die wichtigste Trennung#
In Antra gibt es zwei grobe Fehlerstile:
- Rueckgabewerte, die Erfolg oder Fehlschlag ausdruecken
- 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 valuedie(...)error(...)als strukturierter Fehlerwerttry/catchzum 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::Systemsowie Format-/Decode-APIs wieHTML.parse(...),JSON.parse(...)oderAND.encode/decode(...)bewusst%Err[err]und dannerr.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[...]anfoo(...).!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? usererr != undefsize(items) > 0status == :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 unterscheidestif pattern <- expr, wenn du einen kleinen lokalen Pattern-Zweiweg-Fall lesen willstif 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:
useplusResult.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: mitxnormal weitermachen%Break[value]bedeutet: aktuellenfn- oder Callback-Kontext frueh mitvalueverlassen
Fuer Result ist die Lesart also grob:
%Ok[x].^wird zux%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 zux
Daneben gibt es jetzt bewusst eine zweite, weichere Form:
%Some[x].?wird zux%None[].?wird zuundef
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:
Resultist die normale Wahl fuer APIs, bei denen der Fehler selbst wichtige Information traegtOptionpasst 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:
matchfuer einen lokalen Verzweigungspunktuse,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 lokalundef.^propagiert den%Err/%Noneals 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!/tryin 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/catchverwenden
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 perthrowweitergeht
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 mitdie(...)oderthrow 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:
- Normale API mit
%Ok[...]und%Err[...] - Optional eine strenge
...!-Variante fuer Skript- oder CLI-Code %Err[...]gern auch mit strukturierteren Werten wie%Error{...}, wenn der Aufrufer davon profitiertwarn(...)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 APImatchsagt: die Form des Rueckgabewerts entscheidetif valsagt: erst binden, dann eine normale Bedingung pruefenuseundResult.and_then(...)sagen: mehrere Result-Schritte werden linear verkettetthrowsagt: gib den Fehler an einen aeusseren Handler weitererror(...)sagt: ich brauche einen strukturierten Fehlerwertdie(...)sagt: hier ist jetzt wirklich Schlusswarn(...)sagt: das ist auffaellig, aber nicht fatalcontext(...)sagt: gib mir Diagnose-Daten als normalen Wert
