Skip to content

Errors as values

dusk has no exception system and no panic. A function that can fail says so in its return type, hands the failure back as an ordinary value, and the compiler makes the caller face it. This guide covers the error builtin type, fallible function signatures, the must-handle rule, and the handling patterns you will use every day.

error is a builtin type. It is not imported from any library. It carries one piece of data:

  • message: string: a human readable description, read with toString.

Under the hood an error is a pointer to the NUL terminated message text, and the empty, non-error value is a null pointer. A numeric code and a source location are not part of the current representation; they may return in a later release.

The type has four methods:

MethodSignatureMeaning
existsexists() -> boolTrue if this is a real error, not an empty error.
toStringtoString() -> stringFormats the error as a string.
checkcheck(handler: (error) -> void) -> voidIf the error exists, calls handler with it. Otherwise does nothing.
ignoreignore() -> voidAcknowledges the error and throws it away.

You construct an error with struct-literal syntax. error { message: "..." } builds a real error, and error {} builds the empty one that means success:

return (0, error { message: "divide by zero" }) // failure
return (a / b, error {}) // success

toString on the empty error is the empty string, so printing an error you have not checked first cannot crash. It just prints nothing.

Any function that can fail returns a tuple of (T, error):

func pop_back() -> (int32, error)

The caller destructures the tuple at the call site and both values must be bound to named variables. Here is a complete program that defines a fallible function and handles both outcomes:

divide.dusk
// A fallible function returns (value, error). The caller decides what
// failure means.
func safe_div(a: int64, b: int64) -> (int64, error) {
if b == 0 {
return (0, error { message: "divide by zero" })
}
return (a / b, error {})
}
func main() -> int32 {
q, e := safe_div(10, 2)
if e.exists() {
printerr(e)
return 1
}
println(q)
bad, e2 := safe_div(1, 0)
if e2.exists() {
printerr(e2)
return 1
}
println(bad)
return 0
}

printerr is a builtin that works like println but writes to stderr, which is the natural place for error reports.

The error binding must be used, and using an error means one of exactly three things:

  • inspecting it with exists(), usually followed by control flow,
  • handling it with check(...),
  • or explicitly discarding it with ignore().

Leave an error binding untouched and the program does not compile:

a, e := might_fail(1) // e never used below
println(a)
error: unused variable 'e'
error: the error 'e' is never handled; inspect it with exists, handle it with check, or discard it with ignore

Unlike Go, there is no _ suppression. ignore() replaces it, and unlike _ it leaves a visible, searchable mark in the source. When you audit a codebase for swallowed errors, grep ignore finds every one.

The first common shape is control flow that propagates the failure upward. A lambda cannot return from its caller, so this shape uses exists and an ordinary if:

y, e := x.pop_back()
if e.exists() {
std.io.printerr(e)
return 1
}

This is the propagation pattern: report or wrap the error, then return early. Code after the if runs only on success.

The second shape is side-effecting handling that logs and continues. check takes a lambda and calls it only when the error exists:

handle.dusk
func might_fail(n: int64) -> (int64, error) {
if n < 0 {
return (0, error { message: "negative input" })
}
return (n * 2, error {})
}
func main() -> int32 {
// check: log and continue.
a, e1 := might_fail(-5)
e1.check(lambda (err: error) -> void {
printerr(err)
})
println(a)
// ignore: explicit, visible suppression.
b, e2 := might_fail(21)
e2.ignore()
println(b)
return 0
}

Note that check cannot alter control flow in the enclosing function. The lambda’s return leaves the lambda, not the caller. Use it when the right response to failure is a side effect, not an early exit.

When failure genuinely does not matter, say so:

y, e := x.pop_back()
e.ignore() // explicit, visible, greppable suppression

This satisfies the must-handle rule while leaving a record of the decision in the source.

The standard library follows the same convention everywhere. parse_float and parse_int_radix in std.string return the parsed value with an error, so a bad parse is caught, not guessed:

parse-errors.dusk
@import std.string
// parse_float returns (float64, error); a bad parse is caught, not guessed.
func main() -> int32 {
f, fe := parse_float("3.5")
if fe.exists() {
printerr(fe)
return 1
}
println(f + 0.5)
g, ge := parse_float("bad")
if ge.exists() {
println(-1)
} else {
println(g)
}
return 0
}

Sometimes an error is not a defect at all but a normal condition. read_line in std.io returns (string, error), and the error exists at end of input, which is how a read loop knows to stop:

count-lines.dusk
@paradigm procedural
@import std.io
// read_line returns (string, error); the error exists at end of input.
// Read lines until end of input and count them.
func main() -> int32 {
mut count: int64 = 0
mut done: bool = false
while !done {
line, e := read_line()
if e.exists() {
done = true
} else {
print_line(line)
count = count + 1
}
}
print_int(count)
return 0
}

This file declares @paradigm procedural because it uses mut and while; the error handling itself needs no paradigm. See Paradigms for how the directives work.

A function whose only result is success or failure returns a bare error rather than a tuple, and the same must-handle rule applies to the single binding:

werr := write_file("/tmp/out.txt", "persisted")
werr.ignore()
je := join(t) // join(t: thread) -> error
je.ignore()