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:
| Type | Definition | Variants |
|---|---|---|
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:
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:
| Spelling | Desugars to | Meaning |
|---|---|---|
?T | Optional(T) | a T, or absent |
E!T | Result(E, T) | a T, or an error of type E |
(A | B)!T | Result(A | B, T) | the error side is a union type |
!T | Result(_, 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:
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:
- Produced errors. Every
return Err(e)contributese's type. - Propagated errors. Every
trycontributes the entire error set of its
operand's channel.
- 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.
- 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.
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:
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:
| Operand | Enclosing return | Outcome |
|---|---|---|
E!T | F!U, E ⊆ F | Err propagates, widening into F |
E!T | F!U, E ⊄ F | rejected — the operand's error type does not match |
?T | ?U | None propagates |
?T | E!U | rejected — cross-channel |
E!T | ?U | rejected — cross-channel |
| either | non-fallible type | rejected |
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``.
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:
| Form | Shape | Fallback value |
|---|---|---|
| value | expr catch v | v |
| block | expr catch { … } | the block's tail expression, or the block diverges |
| handler function | expr catch f | f(e) — called with the Err payload |
| typed arrow | expr catch (e: E) T => body | the arrow's result |
On an Optional operand the same forms apply, except a None carries no
payload — a handler function takes no parameters:
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 diverge — return, try, or panic — and that is how a
failure crosses channels, in either direction, explicitly:
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:
| Position | Example |
|---|---|
| return slot | return Err(e) in an E!T function |
| call argument | f(Some(3)) where the parameter is ?i32 |
| annotated binding | con o: ?i32 = Some(5) |
| assignment | slot = None where slot: ?*mut i32 |
switch / if patterns | Some(v): { … }, if Ok(v) = r { … } |
| equality operand | c == Red where c: Color |
| nested constructor payloads | Err(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:
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:
| Method | Signature convention | Role |
|---|---|---|
@errmsg | fn (e: E) @errmsg() [*]u8 | the human-readable message; read by the host when an error exits main |
@errstack | fn (e: *con E) @errstack() *con ErrStack | a captured call-stack snapshot, if the type carries one |
For a ready-made rich error, the @errors package exports:
| Item | Type | Notes |
|---|---|---|
Error | struct { msg: [*]u8, stack: ErrStack } | defines both @errmsg and @errstack |
Error.new(msg) | fn (Error) new(msg: [*]u8) Error | captures the stack at construction time, while the interesting frames still exist |
ErrStack | fixed-capacity frame snapshot | capturing allocates nothing; when the stack is deeper than capacity, the innermost frames win |
capture(skip) | fn capture(skip: i32) ErrStack | takes a snapshot directly; skip drops that many innermost frames |
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.)
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``:
fn main() u8 { // error: main must be `fn main() !u8`
return u8(0)
}The host maps main's result onto the process:
main returns | Process 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 |
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.