Skip to content

Errors as values

Geas has no exceptions. A failure is a value the caller matches on, and nothing throws across a language boundary. Every fallible pledge returns a Result or an Option, never a bare value, so the type of a pledge tells you exactly how it can fail.

Two sums are built in and everywhere. Option<T> is Some(T) or None. Result<T, E> is Ok(T) or Err(E). They are how a pledge says “a value or nothing” and “a value or a failure”, and the type checker holds you to handling both arms.

pledge greet(name: String) -> Result<String, GreetError> {
return Ok(prefix + name)
}

An error type is an ordinary sum, declared with is either. Each variant may carry a payload:

DemoError is either BadMood(reason: String) or Empty

You build a variant by naming it, BadMood("asked to fail") or Empty, and it rides the E slot of any Result. The standard library ships a shared sum in std.errors for the failures a small API keeps meeting:

CommonError is either NotFound or Invalid or Exhausted

A contract with a failure vocabulary of its own declares its sum beside the contract, the way the ledger in The store layer declares LedgerError.

match is an exhaustive expression. It must cover every variant, and the checker rejects it if it does not. Each arm binds the payload it matches:

match r {
Ok(v) -> {
return Ok(v * 2)
}
Err(_) -> {
return Err(-1)
}
}

There is no default fallthrough that silently swallows a new variant. Add a variant to a sum and every match over it that does not cover the new arm becomes a compile error, so error handling stays complete as the error type grows.

? propagates an error. f(x)? runs a fallible call, returns its Err from the enclosing pledge immediately, and unwraps its Ok otherwise:

let gate = IntGate.sign()
let d = gate.double_pos(v)?
gate.break()
return Ok(d)

The callee’s error type must be the enclosing pledge’s error type. When the types do not agree, you translate by hand in a match, which keeps every conversion visible in the source. ? works over Option the same way, returning None from the enclosing pledge on a miss.

Errors are not only values inside a body; they drive the lifecycle. Fulfilling a pledge records its outcome, and the outcomes latch:

  • A pledge latches fulfilled on its first Ok, and stays fulfilled.
  • An Err before any Ok latches the pledge broken, with the error payload kept readable.
  • Contract state never regresses.

After every outcome the runtime re-evaluates the contract’s requirements policy, in the priority break, then fulfill, then partial. A single Err can therefore break a whole contract automatically when the policy says so. See Contracts and the lifecycle for the policy itself.

When a contract breaks automatically, the runtime keeps its heap alive so the error payloads stay readable until an explicit break reclaims them. The partial surface reports which subcontracts and pledges are fulfilled, pending, and broken, with the latched errors attached by name. From a Python host driving the payment contract:

c2 = rt.sign("PaymentService")
out = c2.fulfill_sync("charge", "4111 1111", -2.0)
# out == Err(41), and c2 broke automatically
p = c2.partial()
# p.broken == ["Processing"], p.errors == [("charge", 41)]

The host reads the error by the pledge’s name, long after the call that produced it returned. That is the point of latching: a failure is not a transient event you had to catch in the moment, it is durable state on the instance.

An error sum has the same pinned representation as every other sum: a tagged value whose tag is the variant’s declaration index, with its payload alongside. When a pledge returns Err(BadMood("asked to fail")) to a C or Python host, the host receives an ordinary tagged value and reads the reason string out of it. Nothing unwinds, no exception mechanism has to exist on either side of the boundary, and a host in any language handles a Geas failure with an ordinary conditional.

The one channel that is not a value is a runtime status: a state error such as fulfilling a broken contract, or a store backend failure, arrives as a status code through the wait a host already reads. A contract’s own rules stay values in the contract’s own error type; a status means the runtime itself could not complete the operation. The two never share a channel, so a host that reads the status and a pledge that reads the value never race for the same failure.

A standalone program is one Main contract with a run pledge returning Result<Int, E>. The generated wrapper maps the result onto the process exit code: Ok(n) becomes the exit code, and an Err renders itself to stderr and exits 1.

Terminal window
./target/geas-out/main_demo a b c ; echo $? # counts its args, exits 3
./target/geas-out/main_demo fail # takes the Err path, exits 1
  • Contracts and the lifecycle for the policy that turns an Err into a broken contract.
  • The store layer for how store failures split between contract errors and the store status.
  • Interop for reading latched errors from a foreign host.
  • Reference for the normative rules on sums, match, and propagation.