Errors
How q64 expresses fallible computation: Result<T, E> as the core
type, try for propagation, panic / trap for fatal bugs, and
Option<T> for absence (distinct from error).
Status: near-final (v0). Names settled (
try,panic,trap);Errorface surface settled;From-based propagation conversion settled;paniccarries a typed payload via thePanicface. Some surface details (multi-error function patterns, stack-trace policy) firm up with implementation.
Design goals
- Recoverable errors are values. Every recoverable failure goes
through
Result<T, E>— declared in the return type, propagated withtry, never silently unwound. Unrecoverable conditions (broken invariants, cancellation, runtime denials) unwind viapanicwith a typed payload; the unwind path is explicit, visible at signatures (via@no_panic), and catchable only atscopeboundaries. - Propagation is explicit and greppable.
grep '\btry\b'finds every fallible call site;grep '\bpanic\b'finds every typed unwind. No silent unwinding, no hidden?postfix. - The audio thread is panic-free.
@realtimeimplies@no_panic. Recoverable errors useResult; unrecoverable invariants usetrap(no allocation, no unwind). - Optional absence is not failure. A missing value is
Option<T>; a failed attempt to produce a value isResult<T, E>. The two are distinct types, distinct error vocabulary. - AI-friendly serialization.
Result<T, E>and the diagnostic envelope both serialize to the same shape used by Vercel Zero and the modern AI-SDK pattern:{ ok, value? | error? }.
Vocabulary
| Word | Meaning |
|---|---|
Result<T, E> | Tagged union — either Ok(T) or Err(E). |
Option<T> / T? | Tagged union — either Some(T) or None. Sugar: T? ≡ Option<T>. |
| try | Prefix keyword; propagates Err to the enclosing function. |
| panic | Structured user-level unwind with a typed payload (fits Panic). |
| trap | Bare Wasm trap. Module is no longer runnable. No payload, no unwind, no allocation. |
| Error | Face that error types implement (Display + optional source()). |
| Panic | Face that panic-payload types implement (Display + optional code()). |
Result and Option
Both live in the language auto-prelude (per
modules.md §Forbidden — auto-prelude); no import
needed.
pub enum Result<T, E> { Ok(T), Err(E),}
pub enum Option<T> { Some(T), None,}
pub type T? = Option<T> // sugarConstructor casing is PascalCase throughout — Ok / Err /
Some / None follow the same rule as every other enum variant
in the spec (Increment, Get, Io, Cancelled, Rgb, …).
The try keyword
Prefix form. Reads “try this fallible call; if it errs, propagate.”
pub fn read_config -> Result<Config, IoError> { let bytes = try env.fs.read("config.json") let cfg: Config = try bytes.json() Ok(cfg)}Semantics
For try expr where expr evaluates to Result<T, E1> and the
enclosing function returns Result<U, E2>:
- If
exprisOk(v), thetryexpression yieldsv(aT). - If
exprisErr(e1), the enclosing function returnsErr(E2.from(e1))immediately. The conversion uses the auto-preludeFromface — that is,fit E2 : From<E1>must be reachable; otherwise the compiler emitsTYP301.
Type-system rules
tryis only valid inside a function returningResult<_, _>. Using it elsewhere isTYP300.- The compiler infers the source error type
E1fromexprand the target error typeE2from the enclosing return signature, then requiresfit E2 : From<E1>(orE1 == E2).
Why a keyword, not a sigil
try reads at the start of every fallible call site — the line begins
with the visible intent. ? is shorter but trails behind the value
expression; readers (and agents) often miss it on long lines. The
trailing-? form is forbidden on Result — there is one canonical
way to spell “propagate an error”: try. Postfix ? is reserved for
the ?. chain operator on Option<T> (see “Question-mark chaining on
Option,” below); a bare ? on a Result value is TYP305
(“? on Result; use try”).
Variants deferred
Swift has try? (convert error to Option) and try! (panic on
error). q64 does not include either in v0. The same effects are
achievable with explicit code:
let maybe: T? = match fallible() { Ok(v) -> Some(v), Err(_) -> None } // ≈ try?let v: T = fallible().unwrap_or_panic() // ≈ try!If these patterns become idiomatic in practice, a future spec revision
can introduce try? and try! without breaking the v0 surface.
panic and trap
Two ways to abort. Different costs, different use cases.
panic <payload> — structured unwind with a typed payload
panic "invariant broken: count went negative; got {count}" // string payloadpanic Cancelled // unit-struct payloadpanic RuntimeDenied { code: "ENV030", detail: "Net.get(…)" } // struct payloadpanic <expr>unwinds the current task, attachingexpras the payload.expr’s type must fit thePanicface (§“ThePanicface” below); the compiler enforces this atpanicsites.panic "msg"is sugar forpanic(PanicMessage("msg")), wherePanicMessageis an auto-prelude struct fittingPanic. String interpolation works as in any string literal.- The payload allocates in the current scope’s arena (per
concurrency.md). String panics allocate the message; typed panics allocate the payload value. - Unwinding tears down scoped allocators, cancels sibling tasks in
the enclosing
scope, and propagates to the parent scope. The program exits with code 1 if uncaught at the top level (or with the payload’scode()mapped to an exit, when defined — perq64-cli.md). - Carries an effect:
panicrequires the surrounding function to not be@no_panic(which would beEFF100). - Re-raising inside
catch:panic ewheree: Panic(or any type fittingPanic) unwinds with the same payload — there is no separateraiseorrethrowkeyword.
Use when: an invariant the developer believed to hold has been
violated, a runtime check fires (ENV030, cancellation), or a
non-recoverable condition needs to escape to the nearest scope ... catch — and no meaningful in-line recovery is possible.
trap() — bare Wasm trap, no unwind
trap() // no message, no allocation, immediate halt- Emits the Wasm
unreachableinstruction (or equivalent). - No string, no allocation, no unwind.
- No
panicpayload to inspect. - The host engine traps the wasm module — the module is no longer runnable. Browser tab keeps running; worker thread terminates; Wasmtime / Wasmer return their respective “trapped” status.
- Carries an effect:
traprequires the surrounding function to not be@no_trap.
Use when: the path “should be physically impossible to reach” and
allocating a panic message itself would violate the function’s effect
contract — most commonly on the audio thread inside @realtime /
@no_alloc paths.
Effect interactions
| Effect | Allows panic? | Allows trap? | Notes |
|---|---|---|---|
@no_panic | ❌ | ✅ | panic is rejected; trap is fine (no allocation). |
@no_trap | ✅ | ❌ | Rare; “this function must complete or unwind.” |
@no_alloc | ❌ | ✅ | panic would allocate the message — rejected. |
@realtime | ❌ | ✅ | Implies both @no_panic and @no_alloc; trap remains available. |
@pure | ❌ | ❌ | Pure functions can’t terminate the program via either mechanism. |
Audio paths typically use trap() for invariant violations and
return silence / last-known-good for recoverable conditions. They
never panic.
The Error face
pub face Error : Display { fn source(self) -> Option<ref dyn Error> { None } // default: no inner error fn exit_code(self) -> i64 { 1 } // default: process exit 1}- Required: implement
Display(i.e.,fn fmt(self) -> str @pure). - Optional: override
source()to expose an inner error, enabling chain-of-causes display. - Optional: override
exit_code()to map this error to a non-default process exit code. Consumed bymainForm 2 (perenv.md§“mainsignature”) and by theError → Panicbridge below when an uncaught panic carries anError-fitting payload (perq64-cli.md§“Exit codes”). - The face is in the auto-prelude.
Example: an error type with source chain
pub enum LoadConfigError { Io(IoError), Parse(JsonError),}
pub fit LoadConfigError : Display { fn fmt(self) -> str { match self { Io(_) -> "failed to load config from disk", Parse(_) -> "failed to parse config", } }}
pub fit LoadConfigError : Error { fn source(self) -> Option<ref dyn Error> { match self { Io(e) -> Some(ref e), Parse(e) -> Some(ref e), } }}A diagnostic-printer like qube run walks the source chain:
error: failed to load config from diskcaused by: no such file or directory: ./config.jsonWhy a face, not duck-typing
- Grep-ability.
grep '^pub fit .* : Error'enumerates every error type in the qube. Critical for AI agents auditing failure modes. - Auto-derive opportunity. A future revision can auto-derive
Errorfrom any type whose variants are themselves errors, in the same wayEqandHashare auto-derived today. - Capability disclosure. The registry’s effect surface
(
continuum-api.md§“Capability disclosure surface”) can in principle list “what error types this qube introduces” alongside declared effects.
The Panic face
panic accepts a typed payload; any type fitting the Panic face is
a legal payload.
pub face Panic : Display { fn code(self) -> Option<str> { None } // default: no diagnostic code}- Required: implement
Display(fn fmt(self) -> str @pure). - Optional: override
code()to expose a stable string tag that diagnostics, log formatters, and the runtime’s exit-code mapping can match on (e.g."ENV030","CONC012"). - The face is in the auto-prelude.
Auto-prelude payload types
The language ships four blessed Panic-fitting types:
| Type | Shape | Where it comes from |
|---|---|---|
PanicMessage | struct PanicMessage(str) | panic "string" desugars to panic(PanicMessage(s)). |
Cancelled | struct Cancelled | Cancellation observation in @cancel functions; the implicit select cancellation branch; cancel-aware channel ops. code() returns None (cancellation is a runtime control-flow event, not a diagnostic). |
Closed | struct Closed | A blocking recv(ctx) on a closed-and-empty channel (per concurrency.md §“Sender<T, P> and Receiver<T, P> API”). code() returns None. |
RuntimeDenied | struct RuntimeDenied { code: str, detail: str } | Runtime-emitted denials such as with_capabilities (ENV030). The code and detail fields back the values seen on Panic-bound catch variables. |
All three are in the auto-prelude (no import required). User code may
define additional Panic-fitting types freely:
pub struct ParseLimitExceeded { file: str, bytes: i64,}
pub fit ParseLimitExceeded : Display { fn fmt(self) -> str { "parse limit exceeded in {self.file} ({self.bytes} bytes)" }}pub fit ParseLimitExceeded : Panic { fn code(self) -> Option<str> { Some("PARSE_LIMIT") }}Catch syntax
A scope { … } catch { … } block binds the panic payload by type
(per concurrency.md §“Panics across tasks”).
Two forms:
scope { … } catch (e: Panic) { … } // catch-all; e is dyn Panicscope { … } catch (e: Cancelled) { … } // typed; only this payload typecatch (e: Panic)bindse: dyn Panic. Inspect viae.fmt(),e.code(), or bymatch e { c: Cancelled -> …, _ -> … }-style type-test patterns (forthcoming, perconcurrency.md’s “Open items deferred”).catch (e: T)for a concreteT: Panicmatches only when the payload’s runtime type isT; non-matching panics propagate to the enclosing scope.- Multiple
catcharms after onescopeare supported (most specific to least specific); seeconcurrency.md. - Re-raising:
panic einsidecatch (e: …)unwinds with the same payload, propagating to the enclosing scope.
Why a face, not a sealed type
- User extensibility. Library code defines its own panic payload
types —
ParseLimitExceeded,ShaderCompileError,AssertFailure— without modifying a closed enum. - Greppable.
grep '^pub fit .* : Panic'enumerates every panic-payload type in the qube, the same wayErroris greppable. - Capability disclosure. A qube’s set of declared
Panictypes is part of its public surface; the registry surfaces it alongsideeffects.declared.
The Error → Panic bridge
Any type fitting Error auto-fits Panic via a compiler-synthesized
blanket fit:
// Conceptual; the compiler generates this automatically for every Error type.pub fit T : Panic where T: Error { fn fmt(self) -> str { Error.fmt(self) } fn code(self) -> Option<str> { None }}Consequences:
panic eis well-typed for anyewhose type fitsError— applications can convert a recoverable failure into a fatal one without writing a separatePanicfit.- Stage error propagation (per
streams.md§“Error propagation”) andmainForm 1 (per §“mainsignature” inenv.md) rely on this bridge. - The bridge is a blanket fit, not a conversion: the panic
payload’s runtime type is the original error type. A
catch (e: IoError)arm matches the originalIoError; acatch (e: Panic)arm sees it through thePanicface. - The bridge can be shadowed by an explicit
fit T : Panic { … }on a specific error type when the defaultError.fmt/None-coded behavior isn’t right; this is the conventional override path. Two competing fits (the bridge and an explicit one) resolve in favor of the explicit fit (perfaces.md’s coherence rules).
Multi-error functions
Three idioms, in increasing order of escape-hatch-ness:
Library style — define an enum
pub enum ReadJsonError { Io(IoError), Parse(JsonError),}
pub fit ReadJsonError : From<IoError> { fn from(e: IoError) -> Self { Io(e) } }pub fit ReadJsonError : From<JsonError> { fn from(e: JsonError) -> Self { Parse(e) } }
pub fn read_json<T>(path: str) -> Result<T, ReadJsonError> { let bytes = try env.fs.read(path) // IoError -> ReadJsonError via From let value: T = try bytes.json() // JsonError -> ReadJsonError via From Ok(value)}The From impls glue the two error kinds together. This is what
library code should do — precise error types let callers match on
specific failures.
Inline sum type — anonymous union
pub fn read_json<T>(path: str) -> Result<T, IoError | JsonError> { let bytes = try env.fs.read(path) let value: T = try bytes.json() Ok(value)}E1 | E2 is sugar for an anonymous tagged union; the compiler
generates the same code as the enum form. Useful when the error type
is local and naming it adds no value.
Application style — dyn Error
pub fn run -> Result<(), dyn Error> { let cfg = try read_config() let _ = try connect_db(cfg) let _ = try serve() Ok(())}Boxes any error type satisfying the Error face. One return type
fits all failure modes. Pays one allocation per error construction;
acceptable in application code, not on @realtime paths.
Recommendation
- Libraries: precise enum or
E1 | E2inline union. - Application top-level:
dyn Erroris fine. - Real-time /
@no_alloc: precise enum only;dyn Errorboxing is rejected by the effect checker.
Question-mark chaining on Option
The ?. postfix operator chains through Option<T>:
let first_name: str? = user?.profile?.nameEquivalent to:
let first_name: str? = match user { None -> None, Some(u) -> match u.profile { None -> None, Some(p) -> Some(p.name) }}?. is the only ?-sigil form in user code. ? does not appear on
Result — that uses try.
Destructure form (example.md sugar)
let (obj, err) = env.net.get(url).json()if let e = err { return e }// obj is non-None here by flow typingSugar over Result<T, E>:
let (x, y) = exprwhereexpr: Result<T, E>binds:x: Option<T>y: Option<E>- With the invariant: exactly one is
Some.
- After
if let e = err { return ... }, the flow-typer narrowsobjfromOption<T>toTon the fall-through path.
This is the same shape as the { ok, result, error } JSON envelopes
used by Vercel Zero, the AI SDK, and modern API conventions — a q64
Result<T, E> round-trips cleanly to JSON:
{ "ok": true, "value": {...} }{ "ok": false, "error": {...} }The stdlib JSON serializer for Result<T, E> (when T and E
are themselves JSON-serializable) produces this envelope shape.
The ToJson face and its derive surface live in the JSON-stdlib
qube; the language has no @derive(ToJson) built in.
Diagnostic codes
Error-handling diagnostics. Type-checking concerns fall under TYP*;
effect-system concerns under EFF*.
| Code | Short message | When |
|---|---|---|
TYP300 | try requires Result return type | try used in a function whose return type is not Result<_, _>. |
TYP301 | error conversion not available | try needs fit TargetError : From<SourceError>; no such fit is reachable. |
TYP302 | non-exhaustive match on Result | A match over Result doesn’t handle both Ok and Err. |
TYP303 | ?. on non-Option value | ?. chain operator used on a value whose type is not Option<T>. |
TYP304 | mismatched arms in destructure form | let (x, y) = expr where expr is not a Result<_, _>. |
TYP305 | ? postfix on Result | Bare ? used on a Result value; use the try prefix instead. |
TYP306 | panic payload does not fit Panic | The value passed to panic is not a str and its type does not fit Panic. |
TYP307 | catch type is not Panic-fitting | catch (e: T) where T is neither Panic itself nor a type fitting Panic. |
EFF100 | panic in @no_panic function | panic … invoked from a function declared (or transitively required) @no_panic. |
EFF101 | trap in @no_trap function | trap() called from a function declared @no_trap. |
EFF102 | @realtime function calls fallible operation that allocates on error | @realtime cannot construct heap-allocated Err values. |
EFF103 | dyn Error in @no_alloc path | Boxing an error allocates; rejected on @no_alloc paths. |
Auto-prelude additions
The error-handling auto-prelude (no import needed):
| Name | Kind | Provides |
|---|---|---|
Result | enum | Ok(T), Err(E) |
Option | enum | Some(T), None |
T? | sugar | Option<T> |
try | keyword | Result propagation |
panic | keyword | typed-payload structured unwind |
trap | fn | bare wasm trap |
Error | face | the recoverable-error contract |
Panic | face | the panic-payload contract |
PanicMessage | struct | wraps a str payload for panic "msg" sugar |
Cancelled | struct | unit payload signalling cooperative cancellation (per concurrency.md); code() returns None (cancellation is a runtime control-flow event, not a diagnostic) |
Closed | struct | unit payload for blocking recv(ctx) on a closed-and-empty channel (per concurrency.md); code() returns None |
RuntimeDenied | struct | payload for runtime denials (e.g. with_capabilities ENV030); fields code: str, detail: str |
From | face | error conversion target (already in prelude) |
Into | face | error conversion source (already in prelude) |
RangeError | struct | error returned by try_into casts (see types.md §Casts) when a numeric value does not fit the target width. One field: value: i64. Fits Error, Display, Debug. |
Examples
Reading a config
pub enum ConfigError { Io(IoError), Parse(JsonError),}
pub fit ConfigError : From<IoError> { fn from(e: IoError) -> Self { Io(e) } }pub fit ConfigError : From<JsonError> { fn from(e: JsonError) -> Self { Parse(e) } }
pub fit ConfigError : Display { fn fmt(self) -> str { match self { Io(_) -> "couldn't read config from disk", Parse(_) -> "config file is malformed", } }}
pub fit ConfigError : Error { fn source(self) -> Option<ref dyn Error> { match self { Io(e) -> Some(ref e), Parse(e) -> Some(ref e) } }}
pub fn load_config(path: str) -> Result<Config, ConfigError> { let bytes = try env.fs.read(path) let cfg: Config = try bytes.json() Ok(cfg)}Application top-level
fn main { match load_config("config.json") { Ok(cfg) -> serve(cfg), Err(e) -> env.exit(1, "{e.fmt()}"), }}Audio path — uses trap, never panic
pub fit LowPass : Filter<PCM<f32>, @realtime> { fn step(self: ref Self, x: PCM<f32>) -> PCM<f32> @realtime { if self.state == Broken { trap() } // ✓ trap is allowed in @realtime // if self.state == Broken { panic("…") } // ✗ EFF100: @realtime is @no_panic biquad(self, x) }}Option chaining
struct User { profile: Profile? }struct Profile { name: str? }
fn greet(user: User?) { match user?.profile?.name { Some(n) -> env.out("Hello, {n}!"), None -> env.out("Hello, stranger."), }}Open items deferred
try?andtry!shortcuts — pending real-world evidence that the longhand match /unwrap_or_panicis too verbose.- Stack traces in
panic— today the payload is the only carried state. A future revision may attach a captured backtrace alongside the payload (cost: ~few kB per panic; opt-in via@traced_panic). - Type-test patterns in
catch (e: Panic)arms — destructuring adyn Panicby concrete type inside one catch body. Currently expressible by an outercatch (e: Panic)plus a sequence of typed catches; sugar for combined arms is deferred perconcurrency.md. - Automatic
Fromderivation between sub-enums — manually writingfit ReadError : From<IoError>is repetitive. A@derive(From)for enum wrappers may land later. - Effect erasure across
dyn Error— adyn Errorvalue technically erases the source error’s effects. Whether the effect checker should track residual effects throughdynboxing is open.
Related specs
diagnostics.md— toolchain-side diagnostic envelope (different from user-programResult<T, E>).faces.md—Error,Panic,From,Into,Displayfaces.modules.md— auto-prelude listing including these new entries.q64-cli.md—q64 explain <code>to look up anyTYP*/EFF*/ERR*code documentation.qube-cli.md—qube fix --plan/qube fix --applyto drive automated repair from therepairfield of diagnostics.