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 it directly as e.message or format the whole error with toString.

Reading e.message gives the message text for a real error and "" for the empty error, so it is always safe to read. The field is read only (since 0.5.4). Assigning to it, e.message = "...", is rejected, an error's message is read only; build a new error instead, and message is the only field an error carries, so any other name is rejected, error has no field '<name>'; it carries only 'message'.

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).

An error does not compare with == or !=. Since 1.2.0 comparison closed on a named list of types, and error is not one of them, so e == f and e != f are both rejected at check, an error does not compare; test it with exists(). The pointer identity such a compare would read says nothing about whether an error is real, which is the question you actually want answered. Ask it with exists(), and compare an error’s text by reading e.message on each side, since a string compares by content. See Comparison for the full comparable-type list.

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. Binding the whole pair to one name, r := x.pop_back(), is rejected (since 0.5.4), a fallible result must be destructured; bind the value and the error, so the error can never hide unread inside the pair. The error binding must then 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(),
  • returning it to the caller, which passes the obligation up,
  • or handing it directly to another function’s error parameter, which passes the obligation to that function.
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.

The obligation follows an error into a parameter (since 0.5.3). A function that takes an error parameter must handle it, exactly as a bound error must. An empty body that receives an error and drops it is rejected:

func swallow(err: error) -> void { }
// error: the error 'err' is never handled; inspect it with exists,
// handle it with check, or discard it with ignore

The callee discharges its own error parameter with the same menu: inspect it with exists(), resolve it with check(...), discard it with ignore(), return it, or hand it off to yet another error parameter.

This is also what lets a hand-off discharge the caller. Handing a bound error straight to an error parameter counts as handling it, but the hand-off must be direct: the error has to be the argument itself. Reading it into a fresh value first, or passing it through a passthrough helper, does not count, so sink(fst(e, e2)) still leaves both e and e2 unhandled even though sink takes an error. Passing an error to a parameter that is not itself typed error discharges nothing.

  • 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.