Guides · 07

Optionals and errors

Absence and failure are both spelled in the type. A function that might not have an answer returns ?T; a function that can fail returns E!T. Both desugar to plain enums defined in @coreOptional(T) and Result(E, T) — with no exceptions, no null, and no hidden control flow. If a signature doesn't say it can fail, it can't.

Because they're ordinary enums, everything you already know applies: you switch over them, destructure them with if bindings, and their variants — Some, None, Ok, Err — are ordinary variant names resolved against the expected type, not keywords. Two operators, try and catch, exist purely as ergonomic ways to consume them.

No null

Vyi has no null, nil, or undefined. A value that might be absent is an Optional, spelled ?T:

vyi
fn indexOf(s: [*]u8, want: u8) ?i32 {
    for i in 0..s.len {
        if s[i] == want { return Some(i) }
    }
    return None
}

The payoff is at the pointer types: a *T is always valid by construction — there is no null pointer to check for. A pointer that might be absent is an ?*T, and the type system forces you to unwrap it before dereferencing. Absence never travels in disguise (guide 09 — Pointers and memory).

?T in practice

Construction is explicit. Where an ?i32 is expected you write Some(x) or None yourself — a bare i32 does not silently become a Some:

vyi-error
fn half(n: i32) ?i32 {
    return n / 2        // error: cannot assign i32 to Optional(i32)
}

Some and None resolve type-directed: the expected type at the use site says which enum's variants they are. That works at every value-flow position — returns, arguments, assignments, initialisers — as long as there is an expected type. An unannotated binding has none, so it needs the annotation:

vyi-error
fn main() !u8 {
    con o = Some(5)     // error: nothing says which enum this Some constructs
    mut a = 0
    if Some(v) = o { a = v }
    return Ok(0)
}

Write con o: ?i32 = Some(5) and it compiles.

Consuming an optional is enum consumption. Three forms cover almost everything:

vyi
import { stdout } from "@io"

fn lookup(k: i32) ?i32 {
    if k == 0 { return None }
    return Some(k * 2)
}

fn main() !u8 {
    // switch — handle both variants explicitly
    mut r = 0
    switch lookup(3) {
        Some(v): { r = v },        // 6
        None:    { r = -1 },
    }

    // if-let — when only the present case matters here
    if Some(v) = lookup(4) { r = r + v }    // +8

    // catch — unwrap with a fallback
    con d = lookup(0) catch 1               // 1
    stdout.writeInt(i64(r))   // prints 14
    stdout.write(" ")
    stdout.writeInt(i64(d))   // prints 1
    stdout.write("\n")
    return Ok(0)
}

(The fourth form, try, propagates the None to your caller — it's covered with errors below, because it behaves identically for both.)

?T is a first-class type expression, usable anywhere a type goes — struct fields, locals, parameters:

vyi
struct Config {
    base: i32,
    port: ?i32,
}

fn effectivePort(c: Config) i32 {
    return c.port catch 80
}

Error unions: E!T and bare !T

An error union E!T reads "an E or a T" and desugars to Result(E, T) — an enum whose Ok variant carries the success value and whose Err variant carries the error by value. No boxing, no allocation, no stack unwinding: an error return is an ordinary return.

vyi
enum ParseError { Empty, BadDigit }

fn parse(s: [*]u8) ParseError!i32 {
    if s.len == 0 { return Err(Empty) }
    mut n = 0
    for c in s {
        if c < u8('0') || c > u8('9') { return Err(BadDigit) }
        n = n * 10 + (i32(c) - 48)
    }
    return Ok(n)
}

As with optionals, construction is explicit — return Ok(n), never a bare n — and the variants resolve type-directed. That resolution reaches inside the construction too: Err(Empty) works because the argument slot of Err expects a ParseError, so Empty resolves against it without writing ParseError.Empty.

Error sets you'd have to write out get tedious, so the error side is inferable: bare !T means Result(_, T) where _ is a hole the compiler fills with the minimal union of error types the body actually produces:

vyi
enum NetErr { Down }
enum BodyErr { Bad }

fn load(n: i32) !i32 {
    if n == 0 { return Err(NetErr.Down) }
    if n > 99 { return Err(BodyErr.Bad) }
    return Ok(n)
}

load's error type is NetErr | BodyErr — exactly the two things it can throw, inferred from its own body (never from its callers). More on the rules below.

There is also an error-only form: bare ! means "can fail, returns nothing on success". It is !()Result(_, ()), with the unit empty tuple as the success payload — exactly as !T is _!T. Success is Ok(()), and the same goes for an explicit error set: E!() names the error and returns nothing:

vyi
import { stdout } from "@io"

enum SaveError { DiskFull }

fn save(ok: bool) ! {
    if ok { return Ok(()) }
    return Err(SaveError.DiskFull)
}

fn main() !u8 {
    try save(true)                        // statement try: nothing to unwrap
    mut failed = 0
    save(false) catch { failed = 1 }      // failure runs the catch block
    stdout.writeInt(i64(failed))   // prints 1
    stdout.write("\n")
    return Ok(0)
}

try — propagate

try expr unwraps the success case; on failure it returns the failure from the enclosing function right there. It's the "pass the buck" operator, and it's how fallibility composes without pyramids of switches:

vyi
enum MathErr { DivZero }

fn div(a: i32, b: i32) MathErr!i32 {
    if b == 0 { return Err(MathErr.DivZero) }
    return Ok(a / b)
}

fn average3(a: i32, b: i32, c: i32) MathErr!i32 {
    con avg = try div(a + b + c, 3)     // on Err: average3 returns that Err
    return Ok(avg)
}

try works in statement position (run for effect, propagate the failure, discard the Ok) and nested anywhere in an expression — return Ok(try f(x)) is a common tail.

It works on optionals with the same shape — try on a ?T unwraps the Some or returns None from the enclosing function:

vyi
fn findHalf(k: i32) ?i32 {
    if k == 0 { return None }
    return Some(k / 2)
}

fn findQuarter(k: i32) ?i32 {
    con h = try findHalf(k)     // on None: findQuarter returns None
    return Some(h / 2)
}

The one rule is the same-channel rule: what try re-returns must fit the enclosing function's own failure channel. A Result propagates through a Result-returning function, an Optional through an Optional-returning one — but not across:

vyi-error
enum LookupErr { Missing }

fn find(k: i32) ?i32 {
    if k == 0 { return None }
    return Some(k)
}

fn get(k: i32) LookupErr!i32 {
    con v = try find(k)     // error: a None carries no error to become an Err
    return Ok(v)
}

The fix is the next operator: crossing channels is a decision — which error should absence become? — so you make it explicitly, with catch.

catch — handle

expr catch … unwraps the success case; on failure it produces a fallback instead. What follows catch picks the form:

  • a valueexpr catch 0 — the fallback itself;
  • a blockexpr catch { … } — statements run, the tail expression is the fallback (or the block diverges with return/panic);
  • a handler functionexpr catch onErr or an inline arrow — called with the error; its result is the fallback.
vyi
import { stdout } from "@io"

enum MathErr { DivZero, Overflow }

fn safeDiv(a: i32, b: i32) MathErr!i32 {
    if b == 0 { return Err(MathErr.DivZero) }
    return Ok(a / b)
}

fn onErr(e: MathErr) i32 {
    switch e {
        DivZero:  { return -1 },
        Overflow: { return -2 },
    }
}

fn main() !u8 {
    con a = safeDiv(40, 4) catch 0                       // value: 10
    con b = safeDiv(9, 0)  catch { con d = 20; d + 1 }   // block tail: 21
    con c = safeDiv(9, 0)  catch onErr                   // handler fn: -1
    con d = safeDiv(9, 0)  catch (e: MathErr) i32 => 7   // arrow handler: 7
    stdout.writeInt(i64(a))   // prints 10
    stdout.write(" ")
    stdout.writeInt(i64(b))   // prints 21
    stdout.write(" ")
    stdout.writeInt(i64(c))   // prints -1
    stdout.write(" ")
    stdout.writeInt(i64(d))   // prints 7
    stdout.write("\n")
    return Ok(0)
}

Because catch settles the failure here, it's also how failures cross channels. A diverging handler block can turn absence into a real error:

vyi
enum LookupErr { Missing }

fn find(k: i32) ?i32 {
    if k == 0 { return None }
    return Some(k * 2)
}

fn get(k: i32) LookupErr!i32 {
    con v = find(k) catch {
        return Err(LookupErr.Missing)   // decide what absence means
    }
    return Ok(v)
}

Functions can be overloaded so they differ only in their failure channel (fn parse(s) i32 next to fn parse(s) ParseErr!i32). A call under try or catch prefers the fallible overload; a bare call prefers the plain one. You'll meet this in libraries that offer both a panicking and a fallible flavor of the same operation.

Inference, in depth

The _ in Result(_, T) is solved from the function's own body. Three cases:

Direct failures. Each return Err(e) contributes e's type. One error type infers to exactly that type; several infer to their minimal union (the NetErr | BodyErr example above).

Propagated failures. A try contributes whatever the callee's failure channel carries, so the set flows up call chains without being written anywhere:

vyi
enum IoErr { Closed }

fn read(c: i32) !i32 {
    if c == 0 { return Err(IoErr.Closed) }
    return Ok(c)
}

fn readPlus(c: i32) !i32 {
    con v = try read(c)     // IoErr folds into readPlus's inferred set
    return Ok(v + 1)
}

readPlus never mentions IoErr, has no Err return of its own — and still has the precise error type IoErr, inferred through the try.

No failures. A !T body with zero failure sites infers the empty set — a result that is statically always-Ok. It still compiles and composes; catch fallbacks on it just become unreachable.

The inferred set is a real union type, so union rules apply — in particular covariant widening: a narrower error set flows freely into a wider channel. That's what lets an explicit signature accept propagation from more precise callees:

vyi
enum NetErr { Down }
enum ParseErr { Bad }

fn fetch(n: i32) NetErr!i32 {
    if n == 0 { return Err(NetErr.Down) }
    return Ok(n)
}

fn loadDoc(n: i32) (NetErr | ParseErr)!i32 {
    con raw = try fetch(n)      // NetErr widens into NetErr | ParseErr
    if raw > 99 { return Err(ParseErr.Bad) }
    return Ok(raw * 2)
}

Inference needs a body to look at. A function type has none, so an erased !T in a signature slot can't be solved — write the error set out:

vyi-error
fn apply(f: fn(i32) !i32, x: i32) i32 {   // error: cannot infer the error type
    return f(x) catch { 0 }
}

@errmsg and @errstack

Any type can be an error — there is no Error base class. What makes an error speak is @errmsg, an ordinary operator method you declare like any other method:

vyi
import { stdout } from "@io"

enum MathErr { DivZero }

fn (e: MathErr) @errmsg() [*]u8 {
    return "division by zero"
}

fn div(a: i32, b: i32) MathErr!i32 {
    if b == 0 { return Err(MathErr.DivZero) }
    return Ok(a / b)
}

fn main() !u8 {
    switch div(1, 0) {
        Ok(n):  { },
        Err(e): { stdout.write(e.@errmsg()) },
    }
    return Ok(0)
}

For a ready-made rich error, the @errors package provides Error — a message plus a stack trace captured at construction time (when the frames still exist), not at propagation — exposing both @errmsg and @errstack:

vyi
import { stdout } from "@io"
import { Error } from "@errors"

fn make() Error {
    return Error.new("boom")    // captures the call stack here
}

fn main() !u8 {
    con e = make()
    con msg = e.@errmsg()       // "boom"
    con stack = e.@errstack()   // frames from inside make()
    stdout.write(msg)           // prints boom
    stdout.write("\n")
    stdout.writeInt(i64(stack.len))   // frame count
    stdout.write("\n")
    return Ok(0)
}

ErrStack is a fixed-capacity snapshot — capturing allocates nothing, so it's safe on any failure path. (@errors also exports capture(skip) for taking a snapshot directly.)

main is fallible

The program entry point is fn main() !u8 — any other signature is rejected. It ties the whole model to the operating system:

  • Ok(code) — the u8 payload becomes the process exit code;
  • Err(e) — the host prints error: plus the error's @errmsg (if it defines one) on stderr and exits with status 1.
vyi
enum BootErr { NoConfig }

fn (e: BootErr) @errmsg() [*]u8 {
    return "config file missing"
}

fn load() BootErr!u8 {
    return Err(BootErr.NoConfig)
}

fn main() !u8 {
    con v = try load()      // Err reaches main → "error: config file missing", exit 1
    return Ok(v)
}

So the buck stops at main: every failure in a Vyi program is either handled with catch (or a switch), or rides try up the call chain and becomes a reported, non-zero exit. There is no third path — and nothing to forget.

See also: reference/error-model, reference/stdlib/core.