Error handling
Nymph has no exceptions and no null. A value that might be absent, or an operation that might fail, says so in its type — with Option<T> or Result<T, E> — and the caller is made to deal with the empty/failing case before it can reach the value. Both types live in the always-available prelude (ambient core), so they need no import.
There is no throw or try/catch. A failure is an ordinary value, handled with match, combinator methods, or postfix ? propagation.
Propagation with ?
Postfix ? extracts the success value and completes the nearest explicit block, loop body, or callable early on failure. For Option<T>, Some(value) produces value and None completes the target with None. For Result<T, E>, Ok(value) produces value and Error(error) completes the target with that same error. The target must produce the same family, and Result error types must match exactly; the success types may differ.
struct Task(title: string)
func title(task: Option<Task>): Option<string> = {
let task = task?
Some(task.title)
}
func validate(code: Result<int, string>): Result<boolean, string> = {
let code = code?
Ok(code > 0)
}Propagation never converts between Option and Result; use .ok(), .err(), or .ok_or(error) explicitly. Like break, ? cannot cross a callable boundary. It can target a labeled block, loop, or callable with value?@label, following the same lexical label rules as break@label:
func label_example(value: Option<int>): Option<string> = target@{
let value = value?@target
Some("${value}")
}Option
Option<T> is either Some(value) — a present T — or None. It's how a function signals "there might not be an answer" without reaching for a sentinel value.
struct Task(title: string, done: boolean)
func first_open(a: Task, b: Task): Option<Task> =
if (!a.done) { Some(a) }
else if (!b.done) { Some(b) }
else { None }Some carries one field, value; construct it positionally (Some(t)) or by name (Some(value = t)), and qualify with the type when you want to be explicit (Option.Some(t), Option.None).
Getting the value back out
The most direct way is match — every case is spelled out, so nothing is skipped:
func or_zero(o: Option<int>): int = match (o) {
Some(value) -> value,
None -> 0,
}For a one-off "is it there?" test without a full match, use is:
func present(o: Option<int>): boolean = o is Some(...)Combinators
Option carries a set of methods for transforming the inside without unpacking it by hand. map applies a function to the Some value (and leaves None untouched); filter keeps a Some only when a predicate holds; and_then chains another Option-returning step; or supplies a fallback Option.
func inc(o: Option<int>): Option<int> = o.map((x: int) -> x + 1)
func keep_even(o: Option<int>): Option<int> = o.filter((x: int) -> x % 2 == 0)
func check_pos(n: int): Option<int> = if (n > 0) { Some(n) } else { None }
func positive(o: Option<int>): Option<int> = o.and_then(check_pos)
func either(a: Option<int>, b: Option<int>): Option<int> = a.or(b)These shine with anonymous-parameter closures — o.map($ + 1) is the same as o.map((x: int) -> x + 1).
Supplying a default
?? (the Unwrap operator) collapses an Option to a plain value by giving the None case a fallback:
func title_or(o: Option<string>): string = o ?? "untitled"unwrap_or_else does the same but computes the fallback lazily from a closure, and unwrap_or_default uses the element type's Default when it has one.
func or_compute(o: Option<int>): int = o.unwrap_or_else(() -> 0)Result
Result<T, E> is either Ok(value) — success carrying a T — or Error(error) — failure carrying an E explaining what went wrong. Reach for it (over Option) when the reason for a failure matters.
enum Priority { Low, Medium, High }
func from_code(n: int): Result<Priority, string> = match (n) {
1 -> Ok(Low),
2 -> Ok(Medium),
3 -> Ok(High),
_ -> Error("unknown priority: ${n}"),
}The failing variant is `Error`, not `Err`
Its field is named error: Error(error = "…"), or positionally Error("…").
Handling both sides
match handles the two variants explicitly:
func describe(r: Result<int, string>): string = match (r) {
Ok(value) -> "ok: ${value}",
Error(error) -> "failed: ${error}",
}Combinators
map transforms the Ok value; map_err transforms the Error; and_then chains another fallible step, short-circuiting on the first Error. This is how a pipeline of fallible operations composes without any early-return syntax — each and_then runs only if the previous step succeeded.
func step(n: int): Result<int, string> =
if (n > 0) { Ok(n - 1) } else { Error("hit zero") }
func run(start: int): Result<int, string> =
Ok(start).and_then(step).and_then(step)struct Fail(code: int)
func wrap_err(r: Result<int, int>): Result<int, Fail> =
r.map_err((c: int) -> Fail(code = c))?? supplies a fallback for the Error case, exactly as it does for Option:
func value_or(r: Result<int, string>): int = r ?? -1Optional chaining
?. maps a field access, method call, or index operation over the value inside an Option or the Ok value inside a Result:
struct User(name: string, tags: #[string]) {
func greeting(prefix: string): string = "${prefix}, ${this.name}"
}
func name(user: Option<User>): Option<string> = user?.name
func greeting(user: Option<User>): Option<string> = user?.greeting("Hello")
func first_tag(user: Option<User>): Option<string> = user?.tags?.[0]
func result_name(user: Result<User, string>): Result<string, string> = user?.nameThese are mapping operations: user?.name is equivalent to user.map((value) -> value.name). None remains None, and Error(error) preserves the same error. The receiver is evaluated once; method arguments and index expressions are evaluated only for Some or Ok, because they are part of the mapped operation.
Mapping does not flatten. If user?.field accesses a field of type Option<T>, its type is Option<Option<T>> (and similarly for nested Result values). Use flatten or and_then when flattening is intended.
Optional chaining is specific to Nymph's canonical Option and Result types. It is not JavaScript null/undefined chaining, and applying it to another receiver type is a type error.
Converting between them
A Result drops its error side with .ok() (keeping the success as an Option) or keeps only the error with .err():
func to_option(r: Result<int, string>): Option<int> = r.ok()
func error_of(r: Result<int, string>): Option<string> = r.err()Going the other way — attaching an error reason to a None — is a plain match:
func to_result(o: Option<int>): Result<int, string> = match (o) {
Some(value) -> Ok(value),
None -> Error("missing"),
}`Option.ok_or` isn't usable yet
Option also declares ok_or/ok_or_else for this direction, but they currently infer their error type as () -> E instead of E (the internal thunk they delegate through leaks into the result type), so any real use fails to type-check. Use the explicit match above until that's fixed — it's a compiler gap, not a language rule.
No exceptions, by design
Because failure is a value and never a hidden control-flow jump, a function's signature is the whole truth about how it can fail: a Result<T, E> return is the only way it reports an error, and the type system won't let a caller ignore it. See Pattern matching for everything match can pull out of an Option/Result, and Operators for ??.