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 @core — Optional(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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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 value —
expr catch 0— the fallback itself; - a block —
expr catch { … }— statements run, the tail expression is the fallback (or the block diverges withreturn/panic); - a handler function —
expr catch onError an inline arrow — called with the error; its result is the fallback.
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:
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:
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:
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:
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:
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:
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)— theu8payload becomes the process exit code;Err(e)— the host printserror:plus the error's@errmsg(if it defines one) on stderr and 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)
}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.