Skip to content

Error handling

dusk has no exception system and no panic. A function that can fail returns an error value alongside its result, and the compiler rejects any call site that does not handle that error. This page is the normative description of the error type, the fallible function shape, and the enforcement rules. For a task-oriented walkthrough, see the errors guide.

error is a builtin type. It is not imported from any library.

An error carries a human readable message. Its representation is a pointer to the NUL terminated message text, and the empty, non-error value is a null pointer.

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

A numeric code and a source location are not part of the current representation. They may return in a later release.

An error is built with struct literal syntax. error {} is the empty error, the value a fallible function returns on success.

return (0, error { message: "divide by zero" }) // a real error
return (a / b, error {}) // the empty error

error has four methods.

MethodSignatureBehavior
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 the error. Otherwise does nothing.
ignoreignore() -> voidExplicitly acknowledges and discards the error.

toString() on the empty error is the empty string, not a null pointer, so printing an empty error is safe (since 0.2.6).

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

func pop_back() -> (int32, error)

Errors are always values. There is nothing to catch and nothing to unwind. A function with no meaningful result returns a bare error instead of a tuple (the builtin write_file(path, contents) -> error is one example), and the handling rules below apply to it the same way.

Errors are values, so ordinary code handles them. Two shapes are common.

First, control flow that propagates an error upward. A lambda cannot return from its caller, so this shape uses exists.

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

Second, side effecting handling that logs and continues, using check.

y, e := x.pop_back()
e.check(lambda (err: error) -> void {
printerr(err)
})

A complete program showing all three handling forms:

errors.dusk
// A fallible function returns (value, error). Every caller must handle it.
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 {
// Propagate upward with exists and control flow.
q, e := safe_div(10, 2)
if e.exists() {
printerr(e)
return 1
}
println(q)
// Log and continue with check. The value beside a real error is 0 here.
bad, e2 := safe_div(1, 0)
e2.check(lambda (err: error) -> void {
printerr(err)
})
println(bad)
// Explicit, greppable suppression with ignore.
r, e3 := safe_div(9, 3)
e3.ignore()
println(r)
return 0
}

The tuple return is destructured at the call site. Both values must be bound to named variables, and the error binding must be used. Using an error means one of these things:

  • inspecting it with exists(), usually followed by control flow,
  • handling it with check(...),
  • explicitly discarding it with ignore(),
  • or returning it to the caller, which passes the obligation up.
y, e := x.pop_back()
e.ignore() // explicit, visible, greppable suppression

Unlike Go, there is no _ suppression. ignore() replaces it. The difference is that ignore() is a visible, searchable acknowledgement in the source, while _ hides the decision.

An unhandled error is a compile error, checked at two levels.

A bare statement that drops a fallible call’s result is rejected (since 0.2.5):

might_fail(1)
// error: this expression's error result is ignored; bind it and
// handle the error with exists, check, or ignore

A bound error that never reaches exists, check, or ignore, and is not returned to the caller, is also rejected (since 0.2.6). Printing the error does not count as handling it:

v, e := might_fail(1)
printerr(e) // printing is not handling
println(v)
// error: the error 'e' is never handled; inspect it with exists,
// handle it with check, or discard it with ignore

Returning the error satisfies the rule, since the caller then faces the same obligation:

propagate.dusk
func parse_pair(b: int64) -> (int64, error) {
if b == 0 {
return (0, error { message: "zero input" })
}
return (b * 2, error {})
}
// Returning the error to the caller counts as handling it.
func doubled(b: int64) -> (int64, error) {
v, e := parse_pair(b)
return (v, e)
}
func main() -> int32 {
v, e := doubled(21)
if e.exists() {
printerr(e)
return 1
}
println(v)
return 0
}

The rule covers builtins too: read_file returns (string, error), spawn returns (thread, error), and join returns a bare error, so each caller resolves the failure through exists, check, or ignore. See builtins and concurrency for those signatures.

  • Errors guide: task-oriented patterns for working with errors.
  • Functions: tuple returns and destructuring bindings.
  • std.io: printerr and the fallible read and parse functions.