Docs · Language

The error model

Failure in Vyi is a value in the return type: absence is ?T, error-or-value is E!T, and both are ordinary enums consumed with switch, try, and catch. This page is the authoritative, precise statement of those rules — desugaring, error-set inference, the try channel discipline, catch handler forms, and the main contract. For the tutorial treatment read guide 07 — Optionals and errors; panics are a separate, non-value channel covered in guide 08 — defer, recover, panic.

Optional and Result are ordinary enums

Both types are plain generic enums defined in @core and available everywhere without an import:

TypeDefinitionVariants
Optional(T)enum { None, Some(T) }None, Some(T)
Result(E, T)enum { Ok(T), Err(E) }Ok(T), Err(E)

They have no special runtime status: you switch over them, destructure them in if bindings, nest them, and store them in fields like any enum. Some, None, Ok, and Err are ordinary variant names, not keywords — they resolve by the bare-variant anchoring rules that apply to every enum. Generic instantiation is memoized, so ?i32 and a spelled-out Optional(i32) are the same type and values flow freely between the two spellings:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con o: Optional(i32) = Some(5)   // same type as ?i32
    con r: Result(bool, i32) = Ok(2)
    mut acc = 0
    if Some(v) = o { acc = acc + v }
    if Ok(v) = r { acc = acc + v }
    stdout.writeInt(i64(acc))
    stdout.write("\n")
    return Ok(0)
}

The language touches them in exactly two places: the type-level sugar below, and the try/catch operators.

Desugaring

The ? and ! type forms are surface spellings, rewritten to the @core enums early in compilation:

SpellingDesugars toMeaning
?TOptional(T)a T, or absent
E!TResult(E, T)a T, or an error of type E
(A | B)!TResult(A | B, T)the error side is a union type
!TResult(_, T)the error set _ is inferred
E!()Result(E, ())fallible, nothing on success
!Result(_, ())fallible, nothing on success, error set inferred

Any type may sit on the error side — an enum, a struct, a union of several. The error travels in the Err payload by value: no boxing, no allocation, no unwinding. An error return is an ordinary return.

The error-only form — bare ! for "can fail, returns nothing on success" — carries the unit empty tuple () as its success payload: a missing success type defaults to () exactly as a missing error set defaults to the inferred _. Success is constructed explicitly as Ok(()); try in statement position runs the call and propagates any failure with nothing left to unwrap:

vyi
enum SaveError { DiskFull }

fn save(ok: bool) SaveError!() {   // explicit error, unit success
    if ok { return Ok(()) }
    return Err(SaveError.DiskFull)
}

fn sync(ok: bool) ! {              // inferred error set: SaveError
    try save(ok)
    return Ok(())
}

fn main() !u8 {
    try sync(true)
    return Ok(0)
}

Inferred error sets

The _ in Result(_, T) is a hole the compiler solves per function, from that function's own body — never from its callers or call sites. The rules:

  1. Produced errors. Every return Err(e) contributes e's type.
  2. Propagated errors. Every try contributes the entire error set of its

operand's channel.

  1. Solution. The hole is the minimal union of the contributed types — a

single contributed type infers to exactly that type; several infer to their union; duplicates collapse.

  1. Empty set. A body with zero failure sites infers the empty set: a result

that is statically always-Ok. It still composes — try and catch on it compile; the failure paths are simply unreachable.

vyi
enum NetErr { Down }
enum IoErr { Closed }

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

fn load(c: i32) !i32 {        // inferred error set: NetErr | IoErr
    if c < 0 { return Err(NetErr.Down) }
    con v = try read(c)       // rule 2: IoErr folds in, unwritten
    return Ok(v)
}

Widening is covariant. The error side follows union subtyping: a narrower error set flows freely into a wider explicit channel, so a function declared (NetErr | ParseErr)!T accepts try on a NetErr!U callee. The reverse — propagating a wider set into a narrower channel — is rejected (see the try channel).

Inference needs a body. A function type has none, so an erased !T in a signature slot cannot be solved and is rejected with cannot infer error type from the surrounding code — write the error set out:

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

The try channel

try expr unwraps the operand's success value; on failure it returns the failure from the enclosing function at that point. It is legal in statement position (propagate, discard the success) and anywhere inside an expression (return Ok(try f(x))).

The operand must be an Optional or a Result, and what try re-returns must fit the enclosing function's own failure channel — the same-channel rule:

OperandEnclosing returnOutcome
E!TF!U, EFErr propagates, widening into F
E!TF!U, EFrejected — the operand's error type does not match
?T?UNone propagates
?TE!Urejected — cross-channel
E!T?Urejected — cross-channel
eithernon-fallible typerejected

The rejections, precisely:

  • Optional into an error-returning function:

`invalid try`: cannot propagate an optional through an error-returning function — a None carries no error to become an Err; use catch to map the None case to an error``.

  • Result into an optional-returning function:

`invalid try`: cannot propagate an error through an optional-returning function — an Err cannot collapse to None; use catch to map the Err case``.

  • Non-fallible enclosing function:

`invalid try`: the enclosing function does not return an error union or optional — handle the failure with catch or a bare call``.

  • Error-set narrowing:

`invalid try`: the operand's error type A | B does not match the enclosing function's A``.

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

Crossing channels is a decision — which error should absence become, what should an error mean where only absence is expected — so the language makes you write it, with catch.

catch

expr catch handler unwraps the operand's success value; on failure it produces the handler's fallback instead. The whole expression has the plain success type T. The handler takes one of four forms:

FormShapeFallback value
valueexpr catch vv
blockexpr catch { … }the block's tail expression, or the block diverges
handler functionexpr catch ff(e) — called with the Err payload
typed arrowexpr catch (e: E) T => bodythe arrow's result

On an Optional operand the same forms apply, except a None carries no payload — a handler function takes no parameters:

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 find(k: i32) ?i32 {
    if k == 0 { return None }
    return Some(k)
}

fn fallback() i32 { return 9 }

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   // typed arrow: 7
    con e = find(0) catch 1                              // Optional unwrap: 1
    con f = find(0) catch fallback                       // no-arg handler: 9
    stdout.writeInt(i64(a + b + c + d + e + f - 47))  // prints 1
    stdout.write("\n")
    return Ok(0)
}

Cross-channel conversion. Because catch settles the failure locally, its block form may divergereturn, try, or panic — and that is how a failure crosses channels, in either direction, explicitly:

vyi
enum LookupErr { Missing }

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

fn parse(k: i32) LookupErr!i32 {
    if k > 99 { return Err(LookupErr.Missing) }
    return Ok(k)
}

fn get(k: i32) LookupErr!i32 {
    con v = find(k) catch {
        return Err(LookupErr.Missing)   // Optional → Result: absence becomes this error
    }
    return Ok(v)
}

fn tryGet(k: i32) ?i32 {
    con v = parse(k) catch { return None }   // Result → Optional: the error is dropped
    return Some(v)
}

Overload preference. Functions may be overloaded so they differ only in their failure channel. A call under try or catch prefers the fallible overload; a bare call prefers the plain one.

Bare-variant anchoring

A bare variant name — Some, None, Ok, Err, and every variant of every user enum — resolves against the expected enum type at the use site. The anchoring positions:

PositionExample
return slotreturn Err(e) in an E!T function
call argumentf(Some(3)) where the parameter is ?i32
annotated bindingcon o: ?i32 = Some(5)
assignmentslot = None where slot: ?*mut i32
switch / if patternsSome(v): { … }, if Ok(v) = r { … }
equality operandc == Red where c: Color
nested constructor payloadsErr(Empty)Empty anchors to Err's payload type

Anchoring reaches inward through construction: in return Err(Empty) the Err anchors to the function's error channel, and Empty then anchors to Err's payload slot — no qualification needed at either level. Where there is no expected type, there is nothing to anchor to and the program is rejected (could not determine type) — qualify (Color.Red) or annotate:

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

Errors by value: @errmsg, @errstack, and @errors

Any type can be an error; there is no base class and no registration. Two operator methods give errors behavior, declared like any other method:

MethodSignature conventionRole
@errmsgfn (e: E) @errmsg() [*]u8the human-readable message; read by the host when an error exits main
@errstackfn (e: *con E) @errstack() *con ErrStacka captured call-stack snapshot, if the type carries one

For a ready-made rich error, the @errors package exports:

ItemTypeNotes
Errorstruct { msg: [*]u8, stack: ErrStack }defines both @errmsg and @errstack
Error.new(msg)fn (Error) new(msg: [*]u8) Errorcaptures the stack at construction time, while the interesting frames still exist
ErrStackfixed-capacity frame snapshotcapturing allocates nothing; when the stack is deeper than capacity, the innermost frames win
capture(skip)fn capture(skip: i32) ErrStacktakes a snapshot directly; skip drops that many innermost frames
vyi
import { Error } from "@errors"
import { stdout } from "@io"

fn make() Error {
    return Error.new("boom")    // stack captured here, inside make
}

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

Capture is at construction, not propagation: by the time an Err has ridden try up several frames, the frames that explain it have already returned.

An error caught from a multi-member inferred set has a union type, and @errmsg on it is still one call: the method must be defined on every member, and the call dispatches to the live member's method at runtime. (Union-receiver dispatch is general — differing member return types widen into a union, known params monomorphize per member; see methods & dispatch.)

vyi
import { stdout } from "@io"

enum SaveError { DiskFull }
enum NetError { Timeout }

fn (e: SaveError) @errmsg() [*]u8 {
    return "disk full"
}

fn (e: NetError) @errmsg() [*]u8 {
    return "timed out"
}

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

fn send(ok: bool) NetError!() {
    if ok { return Ok(()) }
    return Err(NetError.Timeout)
}

fn sync(a: bool, b: bool) ! {   // inferred error set: SaveError | NetError
    try save(a)
    try send(b)
    return Ok(())
}

fn main() !u8 {
    switch sync(true, false) {
        Ok(_): { return Ok(1) },
        Err(e): {
            con m = e.@errmsg()              // NetError is live: "timed out"
            stdout.writeInt(i64(m[0] - u8('t')))   // prints 0
            stdout.write("\n")
            return Ok(0)
        },
    }
}

main

The entry point is fn main() !u8. The error set may be inferred (!u8) or explicit (BootErr!u8); any other shape is rejected with `main must be fn main() !u8` — return Ok(code) for the exit status, or let an error propagate to the host``:

vyi-error
fn main() u8 {          // error: main must be `fn main() !u8`
    return u8(0)
}

The host maps main's result onto the process:

main returnsProcess behavior
Ok(n)exits with status n
Err(e)prints error: followed by e.@errmsg() (empty if the error type defines none) on stderr; 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)
}

A panic is not an error value and does not use this channel: an uncaught panic prints its reason and a stack trace and exits with status 101 (guide 08).

See also: guide 07 — Optionals and errors · types · stdlib/core.