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"
Runtime error: 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
}
=> "plan B: out of coffee"

(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
Type error: raise requires a String! or Error!, got Int!

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]
=> [5, 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
=> 6

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
=> 0

The fallback can be any term — a literal, a call, a {{ }} record:

config: {{retries: Int!}}! { raise "config missing" }

config rescue {{retries: 3}}
=> module {retries: Int!}

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! }
Type error: object BrokenError is missing `message(): String!`, required by interface Error

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
}
=> "user gone"

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)
}
=> "error: no more tea"

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)
=> "user-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)]
=> ["user-7", "bad input: id", "no such User"]

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
}
=> "division by zero"

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"
}
Type error: unreachable clause: ValidationError is already matched by the Error clause on line 2

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
}
=> 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 }
Type error: bare catch-all `err =>` is no longer supported; bind the error with `err: Error =>` or discard it with `else =>`

Pattern types must implement Error — validated like a case over an interface operand (see Control flow):

lookup(7) rescue { s: String => s }
Type error: type String does not implement interface Error

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" }
Parse error: syntax error: unexpected '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 { }
Type error: rescue requires at least one clause; to replace any error with a value, use the fallback form: `expr rescue value`

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}`
}
=> "escalated: no User"

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}`
}
=> "halving failed: 7 is odd"

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
Type error: operator addition is not defined between types Int! | String! and Int! - Int! from the rescue operand at :1:14 - String! from the rescue clause at :2:3

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)]
=> ["8 quarters to 2", "no luck: 3 is odd"]

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:

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"
}
=> "the assertion failed"

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)
Runtime error: no user 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
}
=> "lookup failed: id must be positive"

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)
=> "returned, not caught"

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")]
=> [1, null]

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 }
Type error: bare catch-all `err =>` is no longer supported; bind the error with `err: Error =>` or discard it with `else =>`

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#