Chapter 9: Errors, Exceptions, and Regrettable Decisions
Chapter 8 introduced objects, classes, inheritance, and traits. Those tools give you useful vocabulary for ordinary data, and they also give you useful vocabulary for failure.
Now we need the next skill: what to do when reality does not follow our assumptions.
- Files are missing.
- Input is weird.
- A module is optional.
- A config value says
"true"where you needed a number.
This chapter is about writing code that fails usefully.
In ZuzuScript, that means getting comfortable with:
- ordinary runtime failures,
- explicit exception flow with
try,catch,throw, anddie, - typed catches,
- expression-form error handling,
- explicit success/failure values with
std/result, - and practical debugging habits.
The goal is not “never fail”. The goal is “fail in a way future-you can repair quickly”.
9.1 Two kinds of bad news: compile-time vs runtime
A helpful first split:
- Compile-time errors: the script cannot be parsed/validated enough to run.
- Runtime errors: the script starts, then something goes wrong while evaluating code.
Compile-time examples
These fail before your program can do useful work:
- syntax mistakes,
- using undeclared names,
- impossible import forms,
- malformed module paths.
Think “the program text is invalid for execution”.
Runtime examples
These happen during evaluation:
- calling something that is not callable,
- failing conversions,
- missing data in a strict code path,
- exceptions you throw yourself.
Think “the text compiled, but execution hit a problem”.
A practical habit:
- fix compile errors immediately,
- design runtime failures so they are catchable, local, and clear.
9.2 The try / catch model in this language
ZuzuScript has explicit exception handling via try and catch.
Basic shape:
try {
risky_call();
}
catch ( Exception e ) {
say "something went wrong: " _ e.message;
}
How it works:
- Code in
tryruns normally until it finishes or throws. - If a value is thrown, catch clauses are checked top-to-bottom.
- First matching
catchruns. - If nothing matches, the throw continues outward.
This left-to-right catch ordering matters. Put more specific catches before broader ones.
9.3 Throwing values: throw and die
You have two common ways to signal failure.
throw expr (object/value throw)
Use throw when you want to propagate a structured object/value.
class Boom extends Exception; throw new Boom( message: "kapow" );
die "message" (string shorthand)
Use die as a shorthand for a message-style failure.
die "config file missing"; // That is a shortcut for: // throw new Exception( message: "config file missing" );
In other words:
throwis for explicit thrown objects/values,dieis the shorthand for string fatal messages.
Both can be handled with matching catch clauses:
let note := "";
try {
throw new Exception( message: "retry me" );
}
catch ( Exception e ) {
note := e.message;
}
say note; # "retry me"
One stylistic guideline:
- use
throwfor structured domain exceptions, - use
diefor quick message-level fatal branches.
Both are valid; just keep the distinction consistent in a module.
9.4 Catch signatures you can use
From language tests in this repository, catch supports these forms.
Full signature: catch ( Type name )
try {
throw new Exception( message: "full" );
}
catch ( Exception e ) {
say e.message;
}
Name-only shortcut: catch (name)
Defaults to Exception type.
try {
throw new Exception( message: "name-only" );
}
catch (err) {
say err.message;
}
Signature-less shortcut: catch { ... }
Defaults to Exception e.
try {
throw new Exception( message: "default binding" );
}
catch {
say e.message;
}
If you use shortcut catches, keep blocks short and obvious. In larger code, explicit catch ( Exception e ) is often easier to scan.
9.5 Typed catches and class hierarchies
Chapter 8 introduced inheritance. Error handling can benefit from the same structure.
You can catch specific classes first, then general classes:
class ConfigError extends Exception;
class MissingConfig extends ConfigError;
try {
throw new MissingConfig( message: "config.toml missing" );
}
catch ( MissingConfig e ) {
say "create file: " _ e.message;
}
catch ( ConfigError e ) {
say "config issue: " _ e.message;
}
catch ( Exception e ) {
say "fallback: " _ e.message;
}
Order this from narrow to broad. If you put broad first, specific handlers may never run.
Also useful: Any can be used as a broad catch type when you truly want “catch whatever was thrown here and translate it now”.
Use broad catches sparingly. They are powerful, but can hide design mistakes if overused.
9.6 try/catch as an expression (not only a statement)
This is a great feature for ergonomic code.
try/catch can evaluate to a value:
- if
trysucceeds, expression value is from thetryblock, - if matched catch runs, expression value is from that catch block.
const port := try {
parse_port( env{PORT} );
}
catch ( Exception e ) {
8080;
};
That semicolon after the closing catch block matters in declaration contexts.
Expression form is ideal when:
- you need a default value,
- you want one local “attempt + fallback” unit,
- turning the logic into a whole helper function would be overkill.
If it becomes visually dense, split it into a named helper function.
9.7 Rethrow vs recover: choose intentionally
Inside a catch block, you generally have three choices:
- Recover locally and continue.
- Translate to a domain-specific value/error.
- Rethrow and let higher-level code decide.
Recover locally
function read_retry_limit ( Dict cfg ) → Number {
return try {
// Unary plus coerces config value to a Number, but
// some values cannot be coerced to numbers.
+cfg{retry_limit};
}
catch ( Exception e ) {
// Default is the `try` block threw an exception.
3;
};
}
Translate
class StartupError extends Exception;
function load_required_module () {
try {
from app/critical import boot;
return boot;
}
catch ( Any e ) {
throw new StartupError(
message: "Critical startup module unavailable"
);
}
}
Rethrow
try {
sync_once();
}
catch ( Exception e ) {
log_error( e.message );
throw e;
}
A good rule: low-level code may translate technical details, high-level orchestration decides whether to stop the program.
9.8 Returning structured errors with std/result
Exceptions are not the only way to represent failure. The std/result module provides a small Result class for functions that should return either a success value or an error value without throwing.
from std/result import Result;
function parse_timeout ( value ) {
let parsed := try {
Result.ok( +value );
}
catch ( Exception e ) {
Result.err({
code: "invalid-timeout",
reason: e.message,
input: value,
});
};
if ( parsed.is_err ) {
return parsed;
}
let timeout := parsed.unwrap;
if ( timeout < 1 ) {
return Result.err({
code: "timeout-too-small",
reason: "timeout must be positive",
input: value,
});
}
return Result.ok(timeout);
}
let result := parse_timeout("2500");
if ( result.is_ok ) {
say "timeout: " _ result.unwrap;
}
else {
let err := result.error;
say err{code} _ ": " _ err{reason};
}
A Result has these basic operations:
Result.ok(value)wraps a successful value.Result.err(error)wraps an error value.result.is_okandresult.is_errtell you which side you have.result.valuereturns the ok value, ornullfor an error result.result.errorreturns the error value, ornullfor an ok result.result.unwrapreturns the ok value, but throws if the result is an error.result.unwrap_errreturns the error value, but throws if the result is ok.
Use exceptions when failure should interrupt the current flow: violated invariants, impossible states, required startup dependencies, or a low-level operation that cannot sensibly continue. Exceptions keep the happy path clear, work well with typed catches, and let high-level orchestration decide where recovery belongs. Their cost is that control flow is less visible at the call site, broad catches can hide mistakes, and callers must remember which operations may throw.
Use Result when failure is an expected part of the function's contract: validation, parsing, optional lookups, worker/task responses, or APIs where the caller should inspect structured error data. Result objects make success and failure explicit, travel through ordinary return values, and can carry domain-specific error objects. Their cost is extra checking at every call site. If callers ignore the result, or call unwrap() without checking, the code becomes noisy or just moves the exception to a later line.
A practical rule:
- throw for exceptional interruption,
- return
Resultfor expected, recoverable failure that the caller should handle directly.
9.9 Optional imports and graceful degradation
ZuzuScript supports try import, which is excellent for feature flags and optional dependencies.
from extras/not_real try import MaybeFeature;
if ( MaybeFeature ≡ null ) {
say "Optional feature unavailable; continuing.";
}
This lets you represent “not found” as null binding instead of a hard compile-stop for that specific import request.
You can combine with postfix conditions:
let enabled := true; from extras/not_real try import Maybe if enabled;
But remember one guardrail:
- wildcard import (
*) cannot be combined withtryimport.
So this is rejected:
// invalid from extras/math try import *;
Why this matters for error design:
- required module: regular
importand fail fast, - optional capability:
try importand branch onnull.
That distinction makes startup behaviour obvious to readers.
9.10 Common pitfalls (and how to avoid them)
Pitfall 1: Catching too broadly too early
# less ideal
try { risky(); }
catch ( Any e ) { say "oops"; }
catch ( Exception e ) { ... }
The second catch is unreachable by design. Keep broad catch last.
Pitfall 2: Swallowing errors silently
# risky style
try { write_config(); }
catch ( Exception e ) { }
Always do something explicit:
- log,
- increment a failure counter,
- convert to safe fallback,
- or rethrow.
Pitfall 3: Using exceptions for normal control flow
If a condition is expected and frequent, prefer normal branching.
if ( items.length() > 0 ) {
process_first_item( items.get(0) );
}
else {
say "No items yet";
}
Reserve exceptions for truly exceptional or boundary-failure states.
Pitfall 4: Losing context on rethrow/translate
When translating, preserve useful context in message fields.
throw new StartupError( message: "config load failed in profile=night" );
The future debug session will thank you.
9.11 debug, assert, and always-on warnings
ZuzuScript has two debugging keywords that are controlled by the runtime debug level: debug and assert.
debug level, expr writes a diagnostic line to standard error when level is less than or equal to the current runtime debug level:
debug 1, "loaded config";
debug 2, "config detail: " _ config{name};
At the default debug level 0, those two lines do nothing. At -d1, the first line prints. At -d2, both lines print.
zuzu -d1 script.zzs
zuzu -d2 script.zzs
The message expression is not evaluated when the line is filtered out. That makes debug useful for temporary trace points that would otherwise be expensive or noisy:
debug 2, "full payload: " _ build_large_diagnostic(payload);
Use debug for developer-facing trace output. Do not use it for messages that users need to see during normal execution.
assert expr checks an internal assumption only when debugging is enabled:
function average ( Array values ) {
assert values.length() > 0;
return values.sum() / values.length();
}
At debug level 0, the assertion expression is not evaluated. With debugging enabled, a false assertion throws AssertionException. That makes assert good for invariants that should never be false if the program is correct.
Do not use assert for ordinary input validation:
// Good: user-facing validation.
if ( values.length() = 0 ) {
die "cannot average an empty list";
}
// Good: internal sanity check after validation.
assert values.length() > 0;
Use warn when the diagnostic should always be emitted. warn writes to standard error and adds a newline; it is not gated by the debug level. That makes it suitable for deprecations, fallback notices, and operational messages that should not disappear in normal mode.
9.12 Debugging workflows that actually help
When something breaks, calm, repeatable habits beat heroics.
1) Reproduce with a tiny input
Minimize script state until the failure is stable and quick to rerun.
2) Keep failure messages specific
Prefer messages that include identifiers, path fragments, and stage names, not just “failed”.
die "user import failed at row " _ row_index;
3) Guard unsafe assumptions early
Use fast checks before deep logic:
if ( data ≡ null ) {
die "data cannot be null";
}
4) Isolate risky calls in narrow try blocks
Smaller try scopes make root cause clearer.
let parsed := try {
parse_json(raw);
}
catch ( Exception e ) {
die "invalid JSON payload";
};
5) Prefer deterministic fallback values
If you recover, recover to a known, documented default.
9.13 Mini lab: release pipeline
Let’s wire several ideas together.
class ConfigError extends Exception;
class DeployError extends Exception;
function read_timeout ( Dict cfg ) -> Number {
return try {
+cfg{timeout_ms};
}
catch ( Exception e ) {
1500;
};
}
function load_deployer () {
from tools/deploy try import run_deploy;
if ( run_deploy ≡ null ) {
throw new DeployError(
message: "deploy module missing"
);
}
return run_deploy;
}
function run_release ( Dict cfg ) -> String {
if ( cfg ≡ null ) {
throw new ConfigError( message: "cfg is null" );
}
let timeout := read_timeout(cfg);
let deploy_fn := load_deployer();
return try {
deploy_fn( timeout );
"ok";
}
catch ( DeployError e ) {
"degraded";
}
catch ( Exception e ) {
throw new DeployError(
message: "unexpected deploy failure: " _ e.message
);
};
}
What this does:
- Uses expression-form
try/catchfor value fallback. - Uses
try importfor optional capability detection. - Uses typed domain exceptions.
- Keeps broad catch at the end, translating with context.
This is the robust style we want.
9.14 Practical checklist for production-ish scripts
Before shipping a script, ask:
- Which failures should stop execution immediately?
- Which failures should degrade with defaults?
- Which module imports are truly optional?
- Are catch clauses ordered from specific to broad?
- Do translated/rethrown errors keep useful context?
- Should this API throw, or return a
Resultthe caller can inspect? - Are we avoiding giant
tryblocks that blur root causes?
A tiny checklist like this saves real debugging hours.
9.15 Chapter recap
In this chapter, you learned how error flow works in ZuzuScript:
- compile-time and runtime failures have different jobs,
throwcarries thrown objects/values, whiledieis string shorthand,catchsupports full and shortcut signatures,- typed catches should be ordered narrow-to-broad,
try/catchworks as both statement and expression,std/resultlets APIs return explicit success or failure values,try importenables graceful optional-module behaviour.debug,assert, andwarngive you different levels of diagnostic output and invariant checking.
This chapter gives you the resilience to survive imperfect inputs, missing dependencies, and decisions that did not age well.
Next we will use that resilience while splitting code into modules. Good module boundaries make failure easier to localize, and optional imports give you one more way to degrade gracefully.