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 —tryandcatchon 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: `
invalidtry: cannot propagate an optional through an error-returning function — aNonecarries no error to become anErr; usecatchto map theNonecase to an error`. - Result into an optional-returning function: `
invalidtry: cannot propagate an error through an optional-returning function — anErrcannot collapse toNone; usecatchto map theErrcase`. - Non-fallible enclosing function: `
invalidtry: the enclosing function does not return an error union or optional — handle the failure withcatchor a bare call`. - Error-set narrowing: `
invalidtry: 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)
}error declarations and the open error set
error declares an error type whose members carry their message intrinsically —
no @errmsg to write. It has two surface forms of one mechanism:
error DiskFull "disk is full"
error ParseError {
Unexpected "unexpected token";
Eof "end of input";
}The single form declares one implicit member named for the declaration; the
block form names each member, and a member is referred to by path
(ParseError.Eof). The single form's value is spelled by the bare name
(Err(DiskFull)).
error is also a type: the open, tag-only error set that any error
declaration's member coerces into. A function annotated error!T accepts a
failure from any of them, which is what lets a stdlib signature stay stable
while the errors reaching it grow:
error DiskFull "disk is full"
error ParseError { Unexpected "unexpected token"; Eof "end of input" }
fn store(ok: bool) error!i32 {
if ok { return Ok(5) }
return Err(DiskFull) // coerces into the open `error`
}
fn parse(ok: bool) error!i32 {
if ok { return Ok(20) }
return Err(ParseError.Eof) // so does a member of another declaration
}A value of type error is one scalar word — the member's program-global tag —
so widening costs nothing and carries no payload. Narrow it with switch,
matching members by path, with any as the catch-all:
error FileGone "file not found"
error ParseError { Unexpected "unexpected token"; Eof "end of input" }
fn code(e: error) i32 {
switch e {
FileGone: { return 1 },
ParseError.Unexpected: { return 2 },
ParseError.Eof: { return 3 },
any: { return 9 },
}
}When an error reaches main unhandled, the host prints its intrinsic message
through the same path a hand-written @errmsg uses — error: disk is full,
exit status 1.
Use an error declaration when the failure is a fixed set of named conditions
(the standard library's OutOfMemory, ReadError, WriteError are all this
shape). Use an ordinary enum with @errmsg when the failure needs to carry
data, since an error member holds none.
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() [*con]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: [*con]u8, stack: ErrStack } | defines both @errmsg and @errstack |
Error.new(msg) | fn (Error) new(msg: [*con]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() [*con]u8 {
return "disk full"
}
fn (e: NetError) @errmsg() [*con]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() [*con]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.