Errors: raise and rescue#
An error is a value — an object implementing the Error interface — and error handling is expression-shaped like everything else in Dang (see Control flow): raise cuts the computation short with an error describing what went wrong, and the postfix rescue operator attaches a recovery to any expression, yielding the operand's value when it succeeds and the rescue's when it doesn't.
The examples on this page are live: they share one Dang environment, so later snippets use earlier definitions. Each result is computed and baked in by the docs build — edit a snippet and hit Run ▶ to replay the page in your browser. Blocks that show an error are supposed to fail: the build verifies the failure the same way it verifies the results.
Raising#
In its simplest form, raise takes a message string. The error unwinds to the nearest enclosing rescue — and with no rescue anywhere up the stack, it terminates the program:
raise "something went wrong"
Rescued, the message comes back as the error's message field: raising a String! wraps it in the built-in BasicError, so even a string raise produces a real error value:
{ raise "out of coffee" } rescue {
err: Error => "plan B: " + err.message
}
(Why the braces? rescue binds tighter than raise, so a bare raise x
rescue y reads as raise (x rescue y) — to rescue a raise, wrap it in a block.)
Only a String! or a value implementing Error (below) can be raised:
raise 42
raise is itself an expression, and an expression of any type — a fresh type variable — so it can sit in any branch without breaking the merged result type. halve stays an Int! function even though its else branch raises; and because errors propagate out of calls, the failure surfaces at the caller's rescue:
halve(n: Int!): Int! {
if (n % 2 == 0) {
n / 2
} else {
raise `${n} is odd`
}
}
[halve(10), halve(7) rescue 0]
Rescuing with a fallback#
That halve(7) rescue 0 is the workhorse form: expr rescue fallback yields expr's value — unless anything raises while computing it (an explicit raise, an error propagating out of a call, a runtime error), in which case it yields fallback instead. When nothing raises, the operand passes through untouched:
let attempt = halve(10) rescue -1
attempt + 1
The most useful fallback is often null: it turns "this failed" into "this is absent", which the null machinery — ?? (see Operators) and flow narrowing (see Flow-sensitive narrowing) — already knows how to handle. rescue binds tighter than ?? (and looser than or), so the two chain without parentheses — handle the error, then handle the null:
halve(9) rescue null ?? 0
The fallback can be any term — a literal, a call, a {{ }} record:
config: {{retries: Int!}}! { raise "config missing" }
config rescue {{retries: 3}}
One brace caveat: a single { after rescue always opens a clause block (next sections), never a block fallback — a multi-step fallback is spelled expr rescue { else => { ... } }.
The fallback form replaces any error, so keep the operand small: the narrower the expression in front of rescue, the less it can silently swallow.
The Error interface#
Error is a real interface, declared in the prelude alongside BasicError (see Standard library reference), with a single required field:
interface Error {
message: String!
}
A user error type opts in with implements Error (see Interfaces and unions), and conformance is enforced — leaving out message is a compile error:
type BrokenError implements Error { code: Int! }
A value implementing Error raises as-is — no wrapping — and any additional fields, like resource here, ride along on the raised value for a rescue to read:
type NotFoundError implements Error {
message: String!
resource: String!
}
{ raise NotFoundError(message: "user gone", resource: "User") } rescue {
err: Error => err.message
}
And Error! is an ordinary interface type — a parameter type, a type pattern, anywhere a type goes:
describe(err: Error!): String! { `error: ${err.message}` }
{ raise "no more tea" } rescue {
err: Error => describe(err)
}
Rescue clauses#
To look at the error instead of discarding it, give rescue a clause block: expr rescue { clauses }. Clauses are the same type patterns as case (see Control flow) — binding: Type => expr, with the binding narrowed to the matched type — plus else for a catch-all that discards the error. lookup here raises a different error type for each way it can fail:
type ValidationError implements Error {
message: String!
field: String!
}
lookup(id: Int!): String! {
if (id <= 0) {
raise ValidationError(message: "id must be positive", field: "id")
} else if (id > 100) {
raise NotFoundError(message: `no user ${id}`, resource: "User")
} else {
`user-${id}`
}
}
lookup(7)
A clause block dispatches on the raised error's type, routing each to its own recovery — the binding is the error narrowed to the matched type, extra fields included:
fetch(id: Int!): String! {
lookup(id) rescue {
v: ValidationError => `bad input: ${v.field}`
n: NotFoundError => `no such ${n.resource}`
err: Error => err.message
}
}
[fetch(7), fetch(0), fetch(404)]
The Error interface is itself a valid pattern — a typed catch-all matching any error, including runtime failures like division by zero (a RuntimeError; see the built-in taxonomy below), so .message is always there:
toString(100 / 0) rescue {
err: Error => err.message
}
Clauses are tried top to bottom, first match wins, so specific types go before general ones — and a clause that can never match is a compile error, not a silent no-op. Here the Error interface pattern already matches everything, so the ValidationError clause below it is rejected:
lookup(0) rescue {
e: Error => `something failed: ${e.message}`
v: ValidationError => "never reached"
}
The same check rejects a duplicate type pattern, any clause after an else, and — since a rescue's error is always an Error! — an else following an e: Error catch-all.
To handle an error without inspecting it, else => discards it:
halve(9) rescue {
else => 0
}
And that's the only unbound catch-all — a clause either names the error's type or declines to bind it, so a bare err => is rejected:
lookup(0) rescue { err => err.message }
Pattern types must implement Error — validated like a case over an interface operand (see Control flow):
lookup(7) rescue { s: String => s }
Value patterns have no place in a rescue — there is no operand to compare, only an error to inspect — and they don't even parse:
lookup(404) rescue { 404 => "no user" }
Nor can a clause block be empty — replacing any error with a value is what the fallback form is for:
lookup(7) rescue { }
No match? It re-raises#
When no clause matches, the error is re-raised to the next enclosing rescue — a partial rescue narrows what it handles instead of swallowing the rest. (That's also why it doesn't make the result nullable the way a non-exhaustive case does: a miss re-raises, it never yields null.) Chaining is left-associative, so the next enclosing rescue can simply be the next link of a chain:
lookup(404) rescue {
v: ValidationError => "bad input"
} rescue {
n: NotFoundError => `escalated: no ${n.resource}`
}
Widening#
The operand and the clause arms merge to one type when they can; arms that diverge widen to a union instead, exactly like if branches and case clauses (see Control flow). Here the operand is an Int! and the rescue recovers with a String!, so the whole expression is an Int! |
String! — which case type patterns can take back apart:
let outcome = halve(7) rescue {
err: Error => err.message
}
case (outcome) {
n: Int => "halved fine"
s: String => `halving failed: ${s}`
}
And when a widened union doesn't fit where it ends up — often far from the rescue that built it — the type error cites where each member came from:
let result = halve(7) rescue {
err: Error => err.message
}
result + 1
Rescuing a block#
The operand can be any term — including a block, which puts several steps under one recovery. This is the postfix spelling of what other languages do with a try block:
quarter(n: Int!): String! {
{
let half = halve(n)
let result = halve(half)
`${n} quarters to ${result}`
} rescue {
err: Error => `no luck: ${err.message}`
}
}
[quarter(8), quarter(6)]
The built-in taxonomy#
The runtime classifies its own failures into nameable prelude types, so a rescue can dispatch on the kind of failure instead of string-matching messages:
BasicError— exactly whatraise "some string"produces, nothing else.AssertionError— a failedassert { }block.RuntimeError— interpreter faults: division by zero, failed non-null assertions and casts, invalid enum values.GraphQLError— a GraphQL response reporting errors, with two extra fields:path: [String!]!locates the failing field in the request, andextensions: String!holds the response's extensions object as JSON text ("{}"when absent), ready forJSON.decode.
All of them are ordinary Error implementers, usable as type patterns:
assert { 1 == 2 } rescue {
e: AssertionError => "the assertion failed"
e: RuntimeError => "the interpreter faulted"
}
A failed request keeps the server's own error message, untouched by client-side wrapping, and its path and extensions make the failure inspectable without parsing text:
user(id: "1").alwaysFails rescue {
e: GraphQLError => `${e.message} (at ${toString(e.path)})`
}
Propagation#
Uncaught errors unwind through enclosing function calls until a rescue takes them — above, fetch rescued what lookup raised — and with no rescue all the way up, the program terminates with the error's message:
lookup(404)
A rescue clause can also rethrow: raise err re-raises the same error, or raise a new one to recast it. Either way it propagates to the next enclosing rescue:
{
lookup(-5) rescue {
err: Error => raise `lookup failed: ${err.message}`
}
} rescue {
err: Error => err.message
}
Recasting doesn't destroy the original: a raise inside a rescue arm records the rescued error as the new error's cause, automatically. (A plain raise err re-raise carries no cause — it is the original — and an error type may declare its own cause: Error field to take over the chain explicitly.)
When an error escapes the whole program, the report shows everything gathered along the way — the error's type and public data fields, the raise site, the cause chain, and any sibling failures from a concurrent {{ }} — each with its own highlighted source location:
Error: uncaught DeployError: deploy failed
--> ./ci/main.dang:12:17
|
12 | e: Error => raise DeployError(message: "deploy failed", stage: "push")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
stage: "push"
caused by: error: connection refused
--> ./ci/main.dang:8:17
|
8 | push: String! { raise "connection refused" }
^^^^^^^^^^^^^^^^^^^^^^^^^^
|Jumps are not errors: return, break, and continue pass through a rescue untouched (see Control flow), so an early exit can't be accidentally rescued:
bail(n: Int!): String! {
{
if (n < 0) { raise "negative" }
return "returned, not caught"
} rescue {
err: Error => "caught?!"
}
}
bail(1)
Rescue where failures happen#
rescue catches what fails while the operand runs — which, for GraphQL values, is less than it looks. Selecting an object-typed field only extends a lazy query chain (see GraphQL): no request is sent, so nothing can fail, until a leaf field (one whose underlying schema type is a scalar or enum — including @expectedType-mapped fields like Dagger's sync) or a .{{ }} selection forces the chain. Failures happen at leaves and braces, not where the chain is built.
A rescue wrapped around pure chain-building therefore guards nothing, and the compiler says so:
let notes = repo.file("NOTES.md") rescue null
Warning: this rescue can never fire: `file` only builds a GraphQL query — no request is sent inside the rescue; failures happen where the chain executes — rescue at a leaf field (`.contents`, `.name`, `.digest`) or where the value is used
The fix is to move the rescue to the expression that executes — the leaf:
let notes = repo.file("NOTES.md").contents rescue null
Two related escapes get flagged the same way. When an operand can fail but its result is a still-unexecuted handle, the pipeline behind that handle hasn't run — its failures surface wherever the handle is later forced, outside the rescue:
let base = {
print("picking a base image")
container.from("registry.internal/base:latest") # still lazy on the way out
} rescue null
And assigning a lazy handle to a binding declared outside the rescue defers its failures to that binding's eventual use site:
let img = container.from("alpine")
let ok = {
img = container.from("registry.internal/base:latest") # escapes the rescue
true
} rescue false
These are warnings, not errors: the analysis is conservative, and it stays quiet whenever anything inside the operand could genuinely fail. The dead form also fires on trivially infallible operands — 42 rescue 0, or a rescue around a plain variable read — where the rescue is simply dead code.
When to raise vs. return null#
Meta: a small "when to raise vs. when to return null" table would help here. The rule of thumb: raise when continuing would yield wrong results; return null when absence is normal.
Not every "no result" is a failure. When absence is a normal, expected outcome — a search that can come up empty — return null and let the caller branch (see Flow-sensitive narrowing):
find(name: String!): Int {
if (name == "alice") 1 else if (name == "bob") 2
}
[find("alice"), find("nadia")]
raise is for failures: continuing would produce wrong results, or the failure crosses a boundary — invalid input, a violated contract, a failed HTTP or GraphQL call. lookup above raises rather than returning null because an id you're already holding should resolve; a miss means something went wrong upstream.
| situation | use |
|---|---|
| absence is a normal, expected outcome | return null (nullable type) |
| caller routinely branches on the result | return null / a result value |
| continuing would produce wrong results | raise |
| failure crosses a boundary (validation, HTTP/GraphQL, contract) | raise |
Migrating from try/catch#
rescue replaced Dang's original try { } catch { } blocks. The legacy syntax still parses, so old code fails with a pointer rather than a puzzle — type-checking rejects it with the migration path spelled out:
try { halve(7) } catch { err => 0 }
Run dang fmt -w to migrate: it rewrites try/catch to the equivalent postfix rescue, turning bare err => catch-alls into err: Error => along the way. With the block syntax gone, try and catch are ordinary identifiers again; rescue is the reserved word (see Syntax and literals).
Anti-patterns#
- using
raisefor early exit (usereturn— see Control flow) - using
raiseto signal expected absence (returnnull— see Flow-sensitive narrowing) - rescuing errors you can't do anything about — a fallback swallows every failure in its operand; keep the operand narrow and let the rest propagate