Errors
This chapter documents Zynx error unions, error sets, try, catch, throw, and entry-point error behavior.
Status: current public API unless a rule is marked unsupported or unspecified.
Model
An error union is a checked control-flow channel, not an exception mechanism and not a general-purpose data container. There is no stack unwinding and no dynamic exception type: a function that can fail declares throws(...), each try marks a point where a failure may leave the current function, and the compiler checks statically that every propagated error set is either handled or re-declared. Three consequences follow from that one idea:
- Errors carry small typed diagnostic payloads, not arbitrary data. A variant holds only the fields needed to describe or recover from the failure — a path, a code — and is not a way to return normal results.
- The channel is explicit and statically checked.
throws(...)names what a function can fail with andtrymarks where a failure propagates, so a caller musttry(propagate) orcatch(handle) — a failure never escapes silently. - Handling is local.
catchresolves a failure into a success value, or the error propagates to the caller's declared error set; there is no non-local jump past intervening frames.
Error Unions
T throws(ErrorSet) is an error union whose success value has type T and whose failure values come from ErrorSet. Multiple error sets are written as an explicit union, for example T throws(IOError | ParseError). A nullable success value is written before the throwing clause: T? throws(IOError).
The supported nullable/error-union composition is T? throws(E), which means (T?) throws(E): the operation can fail with E, or succeed with either null or a T value. (T throws(E))?, T throws(E)?, and nested forms such as (T throws(A)) throws(B) are rejected. Nullable error unions are therefore always nullable success payloads, not optional error-union containers.
void throws(E) is not a special status-only form. Its success state contains the ordinary zero-sized value void(), while its failure state contains one E. Fallthrough, return;, and return void(); all construct the same successful value. try, catch, generic error code, and Future composition preserve that typed success even when backend layout needs no payload bytes.
Future<T? throws(E)> follows the same rule. Awaiting it yields T? throws(E); try await future yields T? and propagates E, while (await future) catch { ... } handles E and yields the nullable success type or a fallback value.
Error-set identity is the resolved error declaration. Explicit unions are lists of those declarations. Aliases are unsupported.
Generic error-set parameters
A generic parameter can be bounded to any error set with E: error. Inside the body E is usable wherever an error set is expected — most importantly in a throws(E) clause — and each instantiation binds E to the concrete argument set, so propagation and catch see that set:
fn run<E: error>(f: () throws(E) -> i32) throws(E) -> i32 {
return try f();
}- The argument for an
E: errorparameter must resolve to an error set; passing any other type is a compile error. throws(E)resolves per instantiation: propagating an error set that is not the bound argument set is rejected the same as any other set mismatch.- The bound composes with
&(for exampleE: error & SomeInterface) and is accepted on both functions and types, but is rejected on const-generic, region, and pack parameters.
Functions put thrown errors before the return arrow:
fn read() throws(IOError) -> usize
fn load() throws(IOError | ParseError) -> Data
fn flush() throws(IOError) // shorthand for throws(IOError) -> voidIn function declarations, methods, operators, init, and closures, an omitted return arrow after throws(...) means -> void. A bare throws without an explicit error-set list infers the thrown error set from throw and try in the body; callable types and value error-union types must name their error sets explicitly.
Prefer the shorthand for throwing functions whose success value is void(). Writing throws(...) -> void is accepted for compatibility, but a warning is issued that the return type is unnecessary.
Use a bare fn main() for entry points. main does not take parameters; use std.env.args() for command-line arguments. An executable declares initial filesystem, network, and MMIO authority separately with private @host let name: fs.Dir | net.Network | mmio.Region in _; bindings; these do not become parameters on main. main can use try without listing every propagated error; the runtime prints any propagated error and exits nonzero. main must not return a success value. Explicit throws(...) -> void and -> void are unnecessary on main and produce dedicated entry-point warnings. The old -> void! spelling is rejected; write T throws(ErrorSet) for error unions and bare fn main() for fallible entry points.
Tests And Assertions
Top-level tests are declared with test, a string description, and a block:
test "addition" {
let x = add(1, 2);
assert x == 3, "1 + 2 should be 3";
}Test declarations are not functions: they have no parameter list, no declared return type, and cannot have attributes. In zynx test file.zx mode, test blocks are collected and run by the generated test harness instead of running main. Falling through a test body or using bare return; reports success. Returning an integer status is also allowed; nonzero status reports failure. The first operand of assert must have type exactly scalar bool. Integers, pointers, nullable values, bool masks, and other values have no truthiness and do not convert implicitly to bool; write an explicit comparison such as assert count != 0; or assert ptr != null;. An assert message operand is accepted only inside test blocks and must be str.
Error sets are declared with:
error FileError {
NotFound,
AccessDenied
}Each member is a variant. A bare variant such as FileError.NotFound carries no data. A variant may also declare a typed payload — fields written like constructor parameters — and is then thrown with matching arguments:
error FileError {
NotFound(path: str, code: i32),
AccessDenied,
}
fn open(p: str) throws(FileError) -> i32 {
throw FileError.NotFound(p, 404);
}throw FileError.NotFound(p, 404) typechecks its arguments against the variant's declared fields, exactly like a constructor call; a bare variant takes none. There is no implicit message: a human-readable message is just a str field you choose to declare (for example Failed(msg: str)). Payload fields hold owned values — an owning str field is copied at throw, and again where a catch arm binds it.
Error members may carry a stable integer kind tag with Variant = <int>. A kind tag is a declarative numeric id — for example an errno-derived code returned by C — recorded next to the variant it corresponds to. Tags are optional per variant: a set may mix tagged and untagged variants, and tags must be unique integers. There is no automatic mapping from an integer to a variant, so no fallback tag is required.
Mapping a runtime integer to an error variant is ordinary user code: write a function that inspects the integer and throws the matching variant, with a visible fallback for kinds you do not recognize.
error HostError {
Other = 0,
WouldBlock = 1,
NotFound = 7,
Closed // untagged: never produced by the mapping
}
fn raise(kind: i32) throws(HostError) -> never {
if kind == 1 { throw HostError.WouldBlock; }
if kind == 7 { throw HostError.NotFound; }
throw HostError.Other;
}Indexing an error set (HostError[kind]) is rejected: there is no built-in int-to-variant mapping. Untagged variants keep working as ordinary members and are simply never produced by a hand-written mapping.
return expr; returns a success value from the enclosing function. return always exits the enclosing function — including from inside a catch or match arm, where it returns from the function that contains the catch/match rather than producing the arm's value. throw ErrorSet.Variant; (or throw ErrorSet.Variant(args) for a payload variant) exits the function with an error value. A variant is not a success value, so return ErrorSet.Variant; is rejected — use throw.
try expr requires an error union. It unwraps success and propagates failure from the current function. For T? throws(E), success remains T?; try does not unwrap null. It is rejected in functions that do not return an error union.
Fallible synchronous iteration
for try value in source is the loop form of a repeated written propagation point. It is required exactly when synchronous next() returns T? throws(E). Each failure is propagated before that iteration's binding and body; the enclosing function, Future payload, or Generator error contract must include E. Plain for on that iterator is a hard error, and for try on an infallible next() -> T? is also a hard error.
Header try covers only repeated next() calls. Fallible creation remains separate and visible:
fn consume_all() throws(OpenError | DecodeError) {
let decoder = try open_decoder();
for try item in <-decoder {
consume(<-item);
}
}If one next() fails, the body does not start, ordinary iterator/scope cleanup runs exactly once, and the checked failure leaves through the normal error return edge. For local recovery, use an explicit loop and write iterator.next() catch { ... }; Zynx has no for catch shorthand.
var iterator = make_iterator();
loop {
let item = iterator.next() catch { _ => null };
if item == null { break; }
consume(<-item);
}A defer cleanup boundary cannot propagate a checked error. Inside either ordinary synchronous defer or suspending defer await, every fallible operation must be caught and handled completely before cleanup returns. An escaping throw and a try whose failure could leave cleanup are rejected even when the enclosing function returns a compatible error set: cleanup cannot replace the return, error, break, or continue outcome that selected it. A called function or closure may use its own return/error control internally, but the call observed by cleanup must finish successfully or be handled there.
Error-return traces
@backtrace() is an explicit zero-argument diagnostic statement. It prints the current logical trace and then execution continues with the following statement:
fn inspect() {
@backtrace();
continue_work();
}It is available in debug and optimized release builds. Writing it explicitly opts that call site into the trace work; argument-bearing forms are rejected.
In debug builds a propagating try records the logical frame it propagated from, building an error-return trace along the failure's path. This is especially useful across await, where a failure crosses semantic suspension boundaries that an ordinary native stack no longer reflects. A failure that reaches main unhandled is printed with its qualified variant name and the recorded trace. Debug diagnostics for language safety traps also include a logical trace.
Release builds do not automatically collect error-return trace metadata. Setting ZYNX_BACKTRACE cannot enable that collection; release code that needs a trace uses explicit @backtrace() at the observation point. Automatic debug error-return metadata is freed once the error is handled or reported.
The language guarantees trace presence in these cases and the relative logical frame order from the observation or failure site outward through callers and propagation boundaries. Exact text, addresses, symbol formatting, and native unwind details are unstable. Explicit @backtrace() additionally guarantees that execution continues after printing. An unsafe raw-pointer or provenance precondition violation is outside the language contract and is not guaranteed to become a traced language safety trap; see Memory, Unsafe, And FFI.
Remap on propagate
try expr else E.V(args) propagates a remapped error. When expr fails, the else variant is propagated in place of expr's own failure; on success try expr else ... yields expr's success value, exactly like plain try. The else operand is an error-variant construction typechecked like a throw: its variant must belong to an error set in the current function's throws(...), and its payload arguments must match the variant's declared fields. A bare variant E.V takes no arguments.
error ParseError {
Bad
}
error AppError {
Config(line: i32)
}
fn parse() throws(ParseError) -> i32 {
throw ParseError.Bad;
}
fn load() throws(AppError) -> i32 {
// `parse` throws `ParseError`, not in `load`'s throws set; the remap to
// `AppError.Config` is what propagates to the caller.
return try parse() else AppError.Config(10);
}- Because the remapped variant carries the propagated error,
expr's own error set need not be a subset of the current function'sthrows(...)— only theelsevariant must be. - The original error is discarded on the remap: its payload and automatic debug trace are freed, and the remapped error carries its own fresh debug trace.
try await expr else E.V(args)applies the same remap to an awaited error.
expr catch { ... } requires an error union on the left. Catch patterns are .Variant, .ErrorSet.Variant, or _. A .Variant pattern binds the variant's declared payload fields in declaration order. Only => expr produces an arm value. A block arm completes with void() but remains statement-only; its trailing expression is not an implicit result:
error FileError {
NotFound(path: str, code: i32),
AccessDenied,
}
fn open(p: str) throws(FileError) -> i32 {
throw FileError.NotFound(p, 404);
}
fn main() {
let fd = open("/etc/nope") catch {
.NotFound(path, code) => (() -> i32 {
_ = path;
return code;
})(),
.AccessDenied => -1,
};
_ = fd;
}Each bound field has its declared type and is an owned value, so it stays valid after the error is consumed (an owning str field is copied into the binding). A bare variant has no fields to bind. There is no implicit message binding: a message is bound only when the variant declares a str field for it.
Variant arms must cover every error variant in every listed error set unless a _ wildcard arm is present. Unqualified .Variant is accepted only when that variant name is unique across the error-set union; otherwise use qualified syntax such as .FileError.NotFound.
The success side of catch is the unwrapped success type. Catch arm expression results must convert to that type; there is no independent result-type union inference across catch arms. For T? throws(E), catch arms may return null or T. For T throws(E), a null fallback is rejected unless T itself is nullable. catch on a non-error expression is rejected.
A block-bodied catch arm completes as void(), regardless of whether its last expression has a semicolon. It is legal in statement position, or in value position only when it diverges. Because return always exits the enclosing function, it does not produce an arm's value: a return inside an arm returns from the function that contains the catch, so that arm contributes no value and the following code is not reached on that path:
error LoadError {
Missing,
Denied,
}
fn load() throws(LoadError) -> i32 {
throw LoadError.Missing;
}
fn pick() -> i32 {
let fd = load() catch {
.Missing => 0, // yields 0 as the catch value
.Denied => { return -1; }, // returns -1 from `pick`, not from the arm
};
return fd + 1;
}A diverging arm (one that throws or returns from the function) produces no value; the remaining arms still determine the catch expression's success type.
Member access and optional member access do not implicitly handle errors. For a nullable error-union success value, write try or catch before . or ?.:
let name: str? = (try load_user())?.name;
let fallback: str? = (load_user() catch { _ => null })?.name;The selected success expression must itself have nullable type before ?. is legal. An outer nullable destination cannot wrap a non-nullable receiver to enable optional access; use ordinary . for that receiver and let any contextual result injection happen afterward.
Assignments and returns share the success-side conversion rule: both T and null convert to T? throws(E). An ErrorSet.Variant also converts to an error-union destination as that union's error case — for example when it is assigned to an i32? throws(E) slot — but only when the error set is listed in E. A return never yields a variant this way: return ErrorSet.Variant; is rejected in favor of throw.
try expr may propagate only error sets that are included in the current function's declared return error set. Error-union conversion follows the same rule: success conversion must be valid and source error sets must be a subset of destination error sets. Bare main uses an internal dynamic destination set for this rule; other functions must list the destination error sets explicitly.