Skip to content

Error handling

Pluvial’s philosophy splits errors in two: mistakes that can be caught statically are compile errors (exit 65), while value errors that cannot be known statically — integer division by zero, stack overflow — are runtime errors (exit 70). Fallible operations are modeled with the Result type, so that failure is a value you cannot silently ignore.

Result<T> is either an ok holding a T or an err holding a string message. Result<T, E> lets the error be a struct instead of a string. See Types — Result for the type-level details and the ok / err constructors.

Result<int> r = to_int("42")
  • ok(expr) — a success carrying expr.
  • err("msg") — a failure with a string message; T is inferred from context.
  • err(StructInstance) — a failure carrying a struct, for Result<T, E>.
def safe_div(int a, int b) Result<int> {
if (b == 0) { return err("division by zero") }
return ok(a / b)
}

A Result is not a bool — if (r) { } is a compile error. You must check its state with is ok / is err and only then read .value (the success value) or .error:

Result<int> r = safe_div(10, 2)
if (r is ok) {
println(r.value) # 5
} else {
println(r.error)
}
  • .value is accessible only when r is narrowed to ok; .error only when narrowed to err. Accessing them without narrowing — or on the wrong branch — is a compile error.
  • There is no implicit unwrap, consistent with the no-implicit-conversions rule.
  • r.error’s static type follows the Result shape: string for Result<T>, the struct E for Result<T, E> (so r.error.field works).

Narrowing uses the same flow-typing machinery as nullable values, and a Result is narrowed on both branches. See Control flow — Flow typing.

match is the natural way to peel a Result, with exhaustiveness checked at compile time:

match r {
ok(v) => { println(v) } # v : T, read-only
err(e) => { println(e) } # e : string (or the struct E)
}

Forgetting the err arm is a compile error — failure cannot be silently dropped.

The ?? operator supplies a fallback for a nullable value, short-circuiting when the left-hand side is non-null:

int? maybe = m["key"]
int value = maybe ?? 0 # T? ?? T → T

Every error — compile (65) or runtime (70) — is printed as the same Rust/Elm-style block:

error: cannot apply '+' to int and float
--> main.plu:4:13
|
4 | int x = a + b
| ^
|
= help: convert explicitly, e.g. to_float(intExpr) + floatExpr

The block contains a heading (error: or runtime error:), a --> file:line:col line, a right-aligned gutter with the source line, a caret span under the offending token, and — for four specific error kinds — a = help: suggestion. When stderr is a TTY the block is colored; otherwise output is byte-identical to the uncolored form.

A = help: line is attached only to the four error kinds that map one-to-one onto Pluvial’s safety rules, each pointing to the way to satisfy the constraint:

  1. Mixed int/float arithmetic (no implicit promotion): = help: convert explicitly, e.g. to_float(intExpr) <op> floatExpr
  2. Using a nullable before narrowing: = help: check it first, e.g. if (<var> != null) { ... }
  3. Accessing a Result’s contents before narrowing: = help: check it first, e.g. if (<var> is ok) { <var>.value } else { <var>.error }
  4. Assigning a fallible conversion to a bare T (e.g. to_int(string) -> Result<int> into an int): = help: this value can fail; receive it as Result<T> and unwrap with if (r is ok) { r.value }

Suggestions follow a strict accuracy rule: a = help: line is shown only when there is a single correct fix. Syntax errors, undeclared names, arity mismatches, missing returns, duplicate declarations, division by zero, and stack overflow get the plain error block with no suggestion — when the fix is ambiguous, silence is the correct answer.

For typos in a name, field, method, module export, or built-in, the compiler computes the closest known name (within an edit distance of 3) and appends it:

= help: did you mean: to_upper?

For example, to_uperto_upper, nulnull, or a misspelled field name.