Chapter 8: Error Handling #
How to use try/catch blocks and the throw keyword to handle
recoverable errors. How to use panic() for truly unrecoverable situations. How to add
error context with context() and with_context(). How to use the Result type (Ok/Err)
and the Option type (Some/None) for structured error handling -- including the full
Option API with methods like is_some(), map(), and_then(), or_else(), and
to_list(). How to define typed error constructors for large systems. How to propagate
errors through function call chains. How to design graceful degradation strategies for
agent systems that must remain responsive even when components fail.
Why This Matters #
Think of error handling like a pilot's pre-flight checklist. Before every takeoff, pilots walk through hundreds of checks -- fuel levels, control surfaces, navigation systems. Most of the time, everything is fine. But when turbulence hits at 35,000 feet, that checklist is the difference between a scary story and a disaster.
Your agent programs are the same way. When everything works perfectly, error handling is invisible. But the moment an LLM provider goes down, a network request times out, or a user submits unexpected input, your error handling code is what keeps the system running -- or at least failing gracefully with a helpful message instead of a cryptic crash.
The good news? Neam makes error handling feel less like a chore and more like a superpower. By the end of this chapter, you will have a full toolkit for catching, describing, recovering from, and even anticipating errors. You will write code that handles the unexpected with confidence.
Let us begin.
Why Error Handling Matters for Agents #
Agent systems are inherently prone to failure. An LLM provider may be temporarily unavailable. A network request to a knowledge base may time out. A guardrail may reject an input. A skill call may return malformed data. Unlike a batch script that can simply crash and be restarted, an agent interacting with users or other agents must handle these failures gracefully.
Neam's error handling is designed around a simple principle: make errors visible, contextual, and recoverable by default. The language provides:
- try/catch for catching and handling exceptions at runtime.
- throw for explicitly raising errors from your own code.
- panic() for signaling unrecoverable errors that should halt execution.
- context() and with_context() for annotating errors with additional information as they propagate up the call stack.
- Result maps (a convention using
{ok: true/false, ...}) for functions that can fail without throwing exceptions, plus the nativeOk/ErrResult type. - The Option type (
Some/None) for representing the presence or absence of a value, with a full API includingmap(),and_then(),or_else(), andto_list().
This chapter covers all of these mechanisms, then applies them to real agent error scenarios.
try/catch Blocks #
Basic Syntax #
A try/catch block executes the code inside try. If any runtime error occurs, execution
jumps to the catch block, which receives the error value:
{
try {
let result = 10 / 0;
emit "This line is never reached.";
} catch (err) {
emit "Caught an error: " + str(err);
}
}
Output:
Caught an error: division by zero
The catch block's parameter (err in this example) receives whatever error value was
produced. After the catch block finishes, execution continues with the next statement
after the entire try/catch.
Catching Specific Errors #
You can inspect the error value to decide how to respond:
fun safe_divide(a, b) {
try {
return a / b;
} catch (err) {
emit "Division failed: " + str(err);
return nil;
}
}
{
let result1 = safe_divide(10, 3);
emit "10 / 3 = " + str(result1);
let result2 = safe_divide(10, 0);
emit "10 / 0 = " + str(result2);
}
Output:
10 / 3 = 3.333333
Division failed: division by zero
10 / 0 = nil
Nested try/catch #
Try/catch blocks can be nested. An inner catch handles errors from its own try block. If the inner catch does not handle (or re-throws) an error, the outer catch receives it:
{
try {
emit "Outer try begins.";
try {
emit "Inner try begins.";
let x = nil;
let y = x + 1; // Error: cannot add nil and number
} catch (inner_err) {
emit "Inner catch: " + str(inner_err);
// Error is handled here; outer catch is not triggered.
}
emit "Outer try continues after inner catch.";
} catch (outer_err) {
emit "Outer catch: " + str(outer_err);
}
}
Output:
Outer try begins.
Inner try begins.
Inner catch: cannot add nil and number
Outer try continues after inner catch.
Modify the nested try/catch example above so that the inner
catch block re-throws the error with throw inner_err; instead of handling it.
What happens? Which catch block receives the error now? Run the modified code
and observe how errors propagate from inner to outer handlers.
Throwing Errors with throw #
In addition to catching runtime errors, you can explicitly throw your own errors using
the throw keyword. This lets you signal error conditions from your own code:
fun validate_age(age) {
if (age < 0) {
throw "Age cannot be negative: " + str(age);
}
if (age > 150) {
throw "Age seems unrealistic: " + str(age);
}
return age;
}
{
try {
let age = validate_age(-5);
} catch (err) {
emit "Validation error: " + str(err);
}
try {
let age = validate_age(25);
emit "Valid age: " + str(age);
} catch (err) {
emit "Validation error: " + str(err);
}
}
Output:
Validation error: Age cannot be negative: -5
Valid age: 25
You can throw any value -- strings, numbers, or maps. Throwing a map is useful for structured error information:
fun require_field(data, field_name) {
if (data[field_name] == nil) {
throw {"kind": "MissingField", "field": field_name, "message": "Required field is missing"};
}
return data[field_name];
}
{
let config = {"model": "gpt-4o"};
try {
let provider = require_field(config, "provider");
} catch (err) {
emit "Error kind: " + err.kind;
emit "Missing field: " + err.field;
emit "Message: " + err.message;
}
}
Output:
Error kind: MissingField
Missing field: provider
Message: Required field is missing
Use throw for errors that callers can catch and recover from.
Use panic() for truly unrecoverable situations. throw errors are caught by
try/catch; panic() terminates the program immediately.
panic() -- Unrecoverable Errors #
Some errors should not be caught. If a required configuration is missing, or if the
program reaches a state that should be impossible, use panic() to halt execution
immediately with an error message:
fun require_config(config, key) {
if (config[key] == nil) {
panic("Missing required configuration: " + key);
}
return config[key];
}
{
let config = {"model": "gpt-4o"};
let model = require_config(config, "model");
emit "Model: " + model;
// This will halt the program:
let provider = require_config(config, "provider");
}
Output:
Model: gpt-4o
PANIC: Missing required configuration: provider
panic() is not catchable by try/catch. It terminates the program. Use it
only for truly unrecoverable situations -- violated invariants, missing critical
configuration, or corrupted internal state. For everything else, use try/catch or the
result pattern.
When to Use panic() vs. try/catch #
| Situation | Mechanism |
|---|---|
| Missing required config at startup | panic() |
| LLM provider returns HTTP 500 | try/catch |
| Internal data structure corrupted | panic() |
| User input fails validation | try/catch or result map |
| Network timeout on skill call | try/catch |
| Division by zero in user data | try/catch |
The rule of thumb: if the program cannot possibly continue in a correct state, use
panic(). If the program can recover or can inform the user, use try/catch.
Error Context: context() and with_context() #
When errors propagate through multiple layers of function calls, the original error message alone is often insufficient. Was the division-by-zero in the scoring function? The normalization function? The final aggregation? Error context solves this by annotating errors with additional information at each layer.
Adding Context to Errors #
The context() function wraps an error with a descriptive string:
fun parse_score(raw) {
try {
let score = num(raw);
if (score < 0) {
panic("Score cannot be negative");
}
if (score > 100) {
panic("Score cannot exceed 100");
}
return score;
} catch (err) {
return context(err, "while parsing score from: " + str(raw));
}
}
{
try {
let score = parse_score("abc");
} catch (err) {
emit "Error: " + str(err);
}
}
Output:
Error: while parsing score from: abc: invalid number format
Chaining Context with with_context() #
The with_context() function is a convenience for wrapping a function call. If the call
fails, the context string is automatically prepended:
fun load_agent_config(path) {
return with_context(
fun() { return read_file(path); },
"loading agent config from " + path
);
}
fun initialize_agent(config_path) {
return with_context(
fun() { return load_agent_config(config_path); },
"initializing agent"
);
}
{
try {
let config = initialize_agent("/nonexistent/path.toml");
} catch (err) {
emit "Error: " + str(err);
}
}
Output:
Error: initializing agent: loading agent config from /nonexistent/path.toml: file not found
Notice how the context stacks: the outermost context appears first, followed by each inner context, followed by the root cause. This pattern produces error messages that read like a stack trace in natural language.
The Result Pattern #
Many Neam standard library functions (and the idiomatic style used throughout the stdlib) return result maps rather than throwing errors. A result map has the following shape:
// Success:
{ "ok": true, "value": <the result> }
// Failure:
{ "ok": false, "error": <error details> }
This is visible throughout the Neam stdlib. For example, in std.rag.config:
pub fun build_config(config) {
if (config.retriever == nil) {
return { ok: false, error: config_error("retriever", "Retriever is required") };
}
if (config.llm == nil) {
return { ok: false, error: config_error("llm", "LLM is required") };
}
return { ok: true, value: config };
}
Using the Result Pattern #
fun divide(a, b) {
if (b == 0) {
return {"ok": false, "error": "division by zero"};
}
return {"ok": true, "value": a / b};
}
{
let result = divide(10, 3);
if (result["ok"]) {
emit "Result: " + str(result["value"]);
} else {
emit "Error: " + result["error"];
}
let result2 = divide(10, 0);
if (result2["ok"]) {
emit "Result: " + str(result2["value"]);
} else {
emit "Error: " + result2["error"];
}
}
Output:
Result: 3.333333
Error: division by zero
Structured Error Objects #
For richer error information, return maps as the error value. The Neam stdlib convention
uses a kind field to identify the error type:
fun config_error(field, message) {
return {"kind": "Config", "field": field, "message": message};
}
fun validation_error(field, message) {
return {"kind": "Validation", "field": field, "message": message};
}
fun validate_agent_config(config) {
if (config["provider"] == nil) {
return {"ok": false, "error": config_error("provider", "Provider is required")};
}
if (config["model"] == nil) {
return {"ok": false, "error": config_error("model", "Model is required")};
}
if (config["temperature"] != nil) {
if (config["temperature"] < 0 || config["temperature"] > 2) {
return {"ok": false, "error": validation_error(
"temperature",
"Must be between 0 and 2"
)};
}
}
return {"ok": true, "value": config};
}
{
let config = {"provider": "openai", "temperature": 5.0};
let result = validate_agent_config(config);
if (!result["ok"]) {
let err = result["error"];
emit "Validation failed:";
emit " Kind: " + err["kind"];
emit " Field: " + err["field"];
emit " Message: " + err["message"];
}
}
Output:
Validation failed:
Kind: Validation
Field: temperature
Message: Must be between 0 and 2
Result Helper Functions #
Rather than manually checking result.ok every time, you can create helper functions
that operate on result maps. This is a pattern used throughout the Neam standard library:
// Transform the value inside a successful result
fun result_map(result, transform_fn) {
if (result.ok) {
return {"ok": true, "value": transform_fn(result.value)};
}
return result;
}
// Chain operations that each return a result
fun result_and_then(result, next_fn) {
if (result.ok) {
return next_fn(result.value);
}
return result;
}
// Extract the value or use a default
fun result_unwrap_or(result, default_value) {
if (result.ok) {
return result.value;
}
return default_value;
}
// Transform the error inside a failed result
fun result_map_err(result, transform_fn) {
if (!result.ok) {
return {"ok": false, "error": transform_fn(result.error)};
}
return result;
}
Using these helpers creates clean data pipelines:
fun parse_number(raw) {
try {
return {"ok": true, "value": num(raw)};
} catch (err) {
return {"ok": false, "error": "Invalid number: " + str(raw)};
}
}
fun validate_positive(n) {
if (n > 0) {
return {"ok": true, "value": n};
}
return {"ok": false, "error": "Must be positive, got: " + str(n)};
}
{
// Chain: parse -> validate -> transform
let result = parse_number("42");
let validated = result_and_then(result, validate_positive);
let doubled = result_map(validated, fn(n) { return n * 2; });
emit "Result: " + str(result_unwrap_or(doubled, 0));
// With invalid input
let bad = parse_number("abc");
emit "Bad result: " + str(result_unwrap_or(bad, 0));
}
Output:
Result: 84
Bad result: 0
The Option Type #
Not every absence of a value is an error. Sometimes a value is simply "not there" -- a search that returned no results, an optional configuration field, a user who has not set a profile picture. The Option type represents this concept explicitly.
In Chapter 4, you met the Option type with Some(value) and None. Now it is time
to use it as a full-fledged error-handling tool. Option is a first-class type with
built-in methods -- no helper functions or map conventions required.
Think of Option as a gift box. Some(42) is a wrapped present with a number
inside. None is a beautifully wrapped box with nothing in it. The methods on Option
let you peek inside, swap the contents, or provide a backup gift -- all without the
risk of accidentally opening an empty box and crashing your program.
Creating Option Values #
Some(value) and None are globally available constructors. You can use them
anywhere without importing anything:
{
let maybe = Some(42);
let nothing = None;
emit maybe; // Some(42)
emit nothing; // None
}
Where Options Appear: Safe Access Methods #
Many standard library operations return Option instead of throwing errors. This
is the key design principle: safe access methods return Option, while direct
indexing throws on failure (for when you are certain the data is present).
{
// List safe access methods return Option
let items = [10, 20, 30];
emit items.first(); // Some(10)
emit items.last(); // Some(30)
emit items.get(1); // Some(20)
emit items.get(99); // None (no crash!)
// Empty list -- safe methods return None
emit [].first(); // None
emit [].last(); // None
// Map safe access returns Option
let config = {model: "gpt-4o", temperature: 0.7};
emit config.get("model"); // Some("gpt-4o")
emit config.get("provider"); // None
// Direct indexing still throws on out-of-bounds
// (use when you KNOW the data is present)
emit items[0]; // 10
emit config["model"]; // "gpt-4o"
// items[99] --> ERROR: index out of bounds
// config["missing"] --> ERROR: key not found
}
+-------------------------------------------------------------------+
| |
| Safe access (.get, .first, .last): |
| |
| list.get(1) ---> Some(20) (value present) |
| list.get(99) ---> None (out of bounds -- no crash) |
| map.get("x") ---> None (key missing -- no crash) |
| |
| Direct indexing ([]): |
| |
| list[1] ---> 20 (value present) |
| list[99] ---> ERROR! (out of bounds -- throws) |
| map["x"] ---> ERROR! (key missing -- throws) |
| |
| Rule of thumb: |
| Use .get() when the data MIGHT be absent. |
| Use [] when the data MUST be present. |
| |
+-------------------------------------------------------------------+
Option Method Reference #
The Option type provides a full API for inspecting and transforming optional values:
| Method | Returns | Description | Example |
|---|---|---|---|
.is_some() |
Bool |
true if value is present |
Some(5).is_some() -> true |
.is_none() |
Bool |
true if empty |
None.is_none() -> true |
.unwrap() |
Value |
Extract the value; throws if None |
Some(5).unwrap() -> 5 |
.unwrap_or(default) |
Value |
Extract the value, or return default |
None.unwrap_or(0) -> 0 |
.map(fn) |
Option |
Transform the inner value if present | Some(3).map(fn(x) { return x * 2; }) -> Some(6) |
.and_then(fn) |
Option |
Flat-map: fn itself returns an Option |
Some(3).and_then(fn(x) { return Some(x * 2); }) -> Some(6) |
.or_else(fn) |
Option |
Provide an alternative if None |
None.or_else(fn() { return Some(0); }) -> Some(0) |
.to_list() |
List |
Convert to [value] or [] |
Some(5).to_list() -> [5], None.to_list() -> [] |
Checking Options: is_some() and is_none() #
The simplest way to inspect an Option is with is_some() and is_none():
{
let val = {a: 1}.get("b"); // None
if val.is_none() {
emit "Key not found";
}
let found = {a: 1}.get("a"); // Some(1)
if found.is_some() {
emit "Key exists: " + str(found.unwrap());
}
}
Output:
Key not found
Key exists: 1
Unwrapping: Getting the Value Out #
Use .unwrap() when you are certain a value is present. Use .unwrap_or(default)
when you want a safe fallback:
{
let first = [1, 2, 3].first(); // Some(1)
let missing = [].first(); // None
// unwrap() -- gets the value or throws
emit first.unwrap(); // 1
// unwrap_or() -- gets the value or returns default
emit missing.unwrap_or(0); // 0
// WARNING: unwrap() on None throws an error!
// missing.unwrap() --> ERROR: called unwrap() on None
}
Transforming Options: map() #
The .map(fn) method transforms the inner value if present, leaving None
untouched. The function you pass receives the inner value and returns a new
value, which is automatically wrapped in Some:
{
let maybe = Some(5);
let doubled = maybe.map(fn(x) { return x * 2; });
emit doubled; // Some(10)
let nothing = None;
let still_none = nothing.map(fn(x) { return x * 2; });
emit still_none; // None
}
Flat-mapping: and_then() #
The difference between .map() and .and_then() is subtle but important:
.map(fn)-- the function returns a plain value, and.map()wraps it inSome..and_then(fn)-- the function returns an Option itself, preventing double-wrapping.
Use .and_then() when your transformation might itself produce None:
{
// A function that returns Option
fun parse_positive(raw) {
try {
let n = num(raw);
if (n > 0) { return Some(n); }
return None;
} catch (err) {
return None;
}
}
// and_then chains Option-returning functions
let result = Some("42").and_then(parse_positive);
emit result; // Some(42)
let bad = Some("-5").and_then(parse_positive);
emit bad; // None
let empty = None.and_then(parse_positive);
emit empty; // None
}
Providing Alternatives: or_else() #
The .or_else(fn) method provides a fallback Option when the current one is None.
The function is only called if the Option is None:
{
fun load_from_cache(key) {
// Simulating a cache miss
return None;
}
fun load_from_database(key) {
// Simulating a database hit
return Some("value_from_db");
}
let result = load_from_cache("user:123")
.or_else(fn() { return load_from_database("user:123"); });
emit result; // Some("value_from_db")
}
Converting to List: to_list() #
The .to_list() method converts an Option to a list -- [value] for Some, []
for None. This is useful when you need to merge optional results into a collection:
{
let items = Some(42).to_list();
emit items; // [42]
let empty = None.to_list();
emit empty; // []
// Practical use: collect all present values from a list of Options
let options = [Some(1), None, Some(3), None, Some(5)];
let values = [];
for (opt in options) {
for (v in opt.to_list()) {
push(values, v);
}
}
emit values; // [1, 3, 5]
}
Option Equality #
Options support equality comparisons. Two Some values are equal if their inner
values are equal. None is equal to None:
{
emit Some(1) == Some(1); // true
emit Some(1) == Some(2); // false
emit None == None; // true
emit Some(1) == None; // false
}
Chaining Option Methods: Monadic Pipelines #
The real power of Options is chaining. Each method call returns a new Option, so
you can build transformation pipelines that gracefully handle None at any step:
{
let users = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"}
];
// Chain: get first user -> extract email -> uppercase it
let email = users.first()
.map(fn(user) { return user["email"]; })
.map(fn(e) { return upper(e); })
.unwrap_or("NO EMAIL FOUND");
emit email; // ALICE@EXAMPLE.COM
// Same chain on an empty list -- None flows through safely
let no_email = [].first()
.map(fn(user) { return user["email"]; })
.map(fn(e) { return upper(e); })
.unwrap_or("NO EMAIL FOUND");
emit no_email; // NO EMAIL FOUND
}
Notice how None propagates cleanly through the entire chain without a single if
check. Each .map() call is silently skipped when the Option is None, and
.unwrap_or() at the end provides the fallback.
Here is a more advanced example using and_then for safe map lookups with
transformation:
{
let config = {
"database": {"host": "localhost", "port": 5432},
"cache": {"host": "redis.local", "port": 6379}
};
// Safe chaining through nested maps
let db_port = config.get("database")
.map(fn(db) { return db["port"]; })
.unwrap_or(3306);
emit f"DB port: {db_port}"; // DB port: 5432
// Missing key -- None flows through
let missing_port = config.get("logging")
.map(fn(svc) { return svc["port"]; })
.unwrap_or(8080);
emit f"Log port: {missing_port}"; // Log port: 8080
// Monadic chaining with and_then and or_else
let result = config.get("cache")
.and_then(fn(c) { return c.get("host"); })
.or_else(fn() { return Some("localhost"); })
.unwrap();
emit f"Cache host: {result}"; // Cache host: redis.local
}
+-------------------------------------------------------------------+
| |
| config.get("cache") |
| | |
| v |
| Some({"host": "redis.local", "port": 6379}) |
| | |
| | .and_then(fn(c) { c.get("host") }) |
| v |
| Some("redis.local") |
| | |
| | .or_else(fn() { Some("localhost") }) <-- skipped |
| v |
| Some("redis.local") |
| | |
| | .unwrap() |
| v |
| "redis.local" |
| |
| --- If "cache" key were MISSING: --- |
| |
| config.get("missing") |
| | |
| v |
| None |
| | |
| | .and_then(...) <-- skipped (None passes through) |
| v |
| None |
| | |
| | .or_else(fn() { Some("localhost") }) <-- called! |
| v |
| Some("localhost") |
| | |
| | .unwrap() |
| v |
| "localhost" |
| |
+-------------------------------------------------------------------+
When to Use Option vs. Result #
| Pattern | Use When |
|---|---|
Option (Some/None) |
Absence is normal, not an error (search miss, optional field) |
Result (Ok/Err) |
Failure needs explanation (parse error, validation failure, network error) |
| nil | Simple cases where context is obvious |
Write a chain that takes a list of numbers, gets the .first()
element, uses .map() to multiply it by 10, then uses another .map() to convert
it to a string with str(). Use .unwrap_or("empty") at the end. Test with both
[5, 10, 15] and []. What do you get in each case?
The Native Result Type #
While Option represents "something or nothing," Result represents "success or
failure with an explanation." If Option is a gift box, Result is a delivery
receipt -- it either confirms your package arrived (Ok) or tells you exactly
what went wrong (Err).
Result is a first-class type with the same method-chaining style as Option.
Native Result Syntax #
{
fun safe_divide(a, b) {
if (b == 0) {
return Err("division by zero");
}
return Ok(a / b);
}
let result = safe_divide(10, 3);
emit result.unwrap(); // 3.333333
let bad = safe_divide(10, 0);
emit bad.unwrap_or(0); // 0
// Chaining with .map() and .and_then()
let answer = safe_divide(100, 4)
.map(fn(x) { return x * 2; })
.unwrap_or(0);
emit f"Answer: {answer}"; // Answer: 50
}
Result Method Reference #
| Method | Description | Example |
|---|---|---|
.unwrap() |
Extract the value; errors if Err |
Ok(5).unwrap() -> 5 |
.unwrap_or(default) |
Extract the value, or return default |
Err("oops").unwrap_or(0) -> 0 |
.map(fn) |
Transform the success value | Ok(3).map(fn(x) { return x * 2; }) -> Ok(6) |
.map_err(fn) |
Transform the error value | Err("x").map_err(fn(e) { return "wrapped: " + e; }) -> Err("wrapped: x") |
.and_then(fn) |
Chain operations that themselves return Result |
See below |
.is_ok() |
Returns true if Ok |
Ok(5).is_ok() -> true |
.is_err() |
Returns true if Err |
Err("x").is_err() -> true |
Chaining Results with .and_then() #
The difference between .map() and .and_then() is subtle but important:
.map(fn)-- the function returns a plain value, and.map()wraps it inOkfor you..and_then(fn)-- the function returns a Result itself, allowing you to chain operations that can each independently fail.
{
fun parse_number(raw) {
try {
return Ok(num(raw));
} catch (err) {
return Err(f"Invalid number: {raw}");
}
}
fun validate_positive(n) {
if (n > 0) {
return Ok(n);
}
return Err(f"Must be positive, got: {n}");
}
// Chain: parse -> validate -> double
let result = parse_number("42")
.and_then(validate_positive)
.map(fn(n) { return n * 2; });
emit f"Result: {result.unwrap_or(0)}"; // Result: 84
// Chain with invalid input -- Err short-circuits
let bad = parse_number("abc")
.and_then(validate_positive)
.map(fn(n) { return n * 2; });
emit f"Bad: {bad.unwrap_or(0)}"; // Bad: 0
}
When any step in the chain returns Err, all subsequent .map() and .and_then()
calls are skipped, and the error passes straight through to the end. This is the same
"railway-oriented programming" pattern popular in Rust and functional languages.
Map-Based vs. Native Syntax: A Comparison #
You may encounter older Neam code that uses map-based Result and Option conventions (shown earlier in the Result Pattern section). Here is a side-by-side comparison to help you translate between the two styles:
| Feature | Map Convention | Native Syntax |
|---|---|---|
| Success result | {"ok": true, "value": 42} |
Ok(42) |
| Error result | {"ok": false, "error": "msg"} |
Err("msg") |
| Check success | result["ok"] |
result.is_ok() |
| Extract value | result["value"] |
result.unwrap() |
| Safe extract | result_unwrap_or(result, 0) |
result.unwrap_or(0) |
| Transform value | result_map(result, fn) |
result.map(fn) |
| Chain operations | result_and_then(result, fn) |
result.and_then(fn) |
| Some value | {"kind": "Some", "value": 42} |
Some(42) |
| No value | {"kind": "None"} |
None |
| Check presence | opt.kind == "Some" |
opt.is_some() |
| Extract or default | option_unwrap_or(opt, 0) |
opt.unwrap_or(0) |
Use the native syntax (Ok, Err, Some, None) for all new code.
It is shorter, safer (the runtime validates the types), and supports method chaining
out of the box. The map-based convention is perfectly fine for existing code --
there is no need to rewrite working programs. But when you start a new module or
refactor an old one, prefer the native types.
Structured Error Types #
In larger agent systems, you will encounter many different kinds of errors: configuration errors, network errors, validation errors, parse errors, and more. Organizing these with typed error constructors keeps error handling consistent and readable.
Defining Error Constructors #
Create functions that produce error maps with a consistent structure:
fun config_error(field, message) {
return {"kind": "ConfigError", "field": field, "message": message};
}
fun network_error(url, status, message) {
return {"kind": "NetworkError", "url": url, "status": status, "message": message};
}
fun parse_error(input, message) {
return {"kind": "ParseError", "input": input, "message": message};
}
fun auth_error(reason) {
return {"kind": "AuthError", "reason": reason, "message": "Authentication failed: " + reason};
}
Handling Errors by Kind #
The kind field lets you match on error type and respond appropriately:
fun handle_error(err) {
if (err.kind == "NetworkError") {
if (err.status == 429) {
emit "[WARN] Rate limited on " + err.url + " -- will retry.";
return "retry";
}
emit "[ERROR] Network failure: " + err.message;
return "fail";
}
if (err.kind == "AuthError") {
emit "[ERROR] Auth: " + err.reason + " -- cannot retry.";
return "fail";
}
if (err.kind == "ConfigError") {
emit "[ERROR] Config: missing " + err.field + " -- " + err.message;
return "fail";
}
// Unknown error type
emit "[ERROR] Unexpected: " + str(err);
return "fail";
}
{
let err1 = network_error("https://api.example.com", 429, "Too many requests");
let action = handle_error(err1);
emit "Action: " + action;
}
Output:
[WARN] Rate limited on https://api.example.com -- will retry.
Action: retry
This pattern scales well as your agent system grows. Each module can define its own error
types, and the top-level error handler can dispatch on kind to decide the appropriate
response.
Error Propagation Patterns #
In multi-layered agent systems, errors flow upward through the call stack. Neam supports two propagation strategies: exception propagation (try/catch) and result propagation (result maps checked at each layer).
Exception Propagation #
If a function does not catch an error, it propagates automatically to the caller:
fun step_three() {
// This throws an error
let data = parse_json("not valid json");
return data;
}
fun step_two() {
// Does not catch -- error propagates
return step_three();
}
fun step_one() {
// Catches the error at the top level
try {
return step_two();
} catch (err) {
emit "Pipeline failed: " + str(err);
return nil;
}
}
{
let result = step_one();
}
Result Propagation (Manual) #
With the result pattern, each layer checks the result and either handles the error or passes it upward:
fun fetch_data(url) {
// Simulate a network failure
return {"ok": false, "error": {"kind": "Network", "message": "Connection refused"}};
}
fun process_data(url) {
let result = fetch_data(url);
if (!result["ok"]) {
// Add context and propagate
result["error"]["context"] = "while processing data from " + url;
return result;
}
// Process the data...
return {"ok": true, "value": "processed: " + str(result["value"])};
}
fun run_pipeline(urls) {
let results = [];
let errors = [];
for (url in urls) {
let result = process_data(url);
if (result["ok"]) {
push(results, result["value"]);
} else {
push(errors, result["error"]);
}
}
return {"results": results, "errors": errors};
}
{
let urls = ["https://api.example.com/data1", "https://api.example.com/data2"];
let output = run_pipeline(urls);
emit "Successful: " + str(len(output["results"]));
emit "Failed: " + str(len(output["errors"]));
for (err in output["errors"]) {
emit "Error: " + err["message"] + " (" + err["context"] + ")";
}
}
Output:
Successful: 0
Failed: 2
Error: Connection refused (while processing data from https://api.example.com/data1)
Error: Connection refused (while processing data from https://api.example.com/data2)
Graceful Degradation in Agent Systems #
Production agent systems must continue serving users even when individual components fail. The following patterns demonstrate graceful degradation.
Fallback Agents #
If the primary agent fails, fall back to a simpler model:
agent PrimaryAgent {
provider: "openai"
model: "gpt-4o"
system: "You are a helpful assistant."
}
agent FallbackAgent {
provider: "ollama"
model: "llama3.2:3b"
system: "You are a helpful assistant. Keep responses brief."
}
fun ask_with_fallback(query) {
try {
return PrimaryAgent.ask(query);
} catch (err) {
emit f"[WARN] Primary agent failed: {err}";
emit "[WARN] Falling back to local model.";
try {
return FallbackAgent.ask(query);
} catch (err2) {
emit f"[ERROR] Fallback also failed: {err2}";
return "I'm sorry, I'm unable to process your request right now.";
}
}
}
{
let response = ask_with_fallback("What is Neam?");
emit response;
}
Retry with Backoff #
For transient failures (rate limits, temporary outages), retry with increasing delays:
fun ask_with_retry(agent_ref, query, max_retries) {
let attempt = 0;
let last_error = nil;
while (attempt < max_retries) {
try {
let response = agent_ref.ask(query);
return {"ok": true, "value": response};
} catch (err) {
last_error = err;
attempt = attempt + 1;
emit f"[RETRY] Attempt {attempt} failed: {err}";
if (attempt < max_retries) {
// Wait before retrying (exponential backoff)
let wait_ms = 1000 * attempt;
emit f"[RETRY] Waiting {wait_ms}ms before retry...";
sleep(wait_ms);
}
}
}
return {"ok": false, "error": "All " + str(max_retries) + " attempts failed. Last error: " + str(last_error)};
}
Partial Results #
When processing a batch of items, collect results for items that succeed and report errors for items that fail, rather than aborting the entire batch:
fun process_batch(agent_ref, queries) {
let successes = [];
let failures = [];
for (query in queries) {
try {
let response = agent_ref.ask(query);
push(successes, {"query": query, "response": response});
} catch (err) {
push(failures, {"query": query, "error": str(err)});
}
}
emit "Processed: " + str(len(successes)) + " succeeded, " + str(len(failures)) + " failed.";
return {"successes": successes, "failures": failures};
}
Default Values #
When optional data is missing, supply sensible defaults rather than failing:
fun get_config_or_default(config, key, default_value) {
let value = config[key];
if (value == nil) {
return default_value;
}
return value;
}
{
let config = {"model": "gpt-4o"};
let model = get_config_or_default(config, "model", "gpt-4o-mini");
let temperature = get_config_or_default(config, "temperature", 0.7);
let max_tokens = get_config_or_default(config, "max_tokens", 4096);
emit "Model: " + model;
emit "Temperature: " + str(temperature);
emit "Max tokens: " + str(max_tokens);
}
Output:
Model: gpt-4o
Temperature: 0.7
Max tokens: 4096
Best Practices for Agent Error Handling #
1. Catch at the right level. Do not catch errors in low-level utility functions unless you can meaningfully handle them there. Let errors propagate to the level that has enough context to make a decision (retry, fallback, inform the user).
2. Always add context. When re-throwing or propagating errors, add context that
identifies which operation failed and what inputs were involved. Use context() or add
a context field to result error maps.
3. Log before swallowing. If you catch an error and choose not to propagate it
(graceful degradation), always emit or log the error first. Silent failures are the
hardest bugs to diagnose.
4. Use the result pattern for expected failures. If a function is designed to sometimes fail (validation, lookup, parsing), return a result map. Reserve try/catch for truly unexpected errors.
5. Use panic() sparingly. Reserve panic() for invariant violations and startup
configuration checks. Never use panic() in a code path that handles user input.
6. Design for partial failure. In multi-agent systems, one agent failing should not crash the entire pipeline. Use the partial-results pattern to process what you can and report what you cannot.
7. Set timeouts. Every external call (LLM provider, API, database) should have a
timeout. Configure these in neam.toml:
[agent.limits]
timeout-seconds = 300
max-retries = 3
8. Test your error paths. Write test cases that deliberately trigger errors. Ensure your catch blocks and fallback logic actually work:
// Test that fallback works when primary fails
{
emit "=== Error Handling Tests ===";
// Test 1: Division by zero is caught
try {
let x = 1 / 0;
emit "FAIL: Should have thrown";
} catch (err) {
emit "PASS: Division by zero caught";
}
// Test 2: Nil access is caught
try {
let m = nil;
let v = m["key"];
emit "FAIL: Should have thrown";
} catch (err) {
emit "PASS: Nil access caught";
}
emit "=== Tests Complete ===";
}
Summary #
Neam provides a comprehensive, layered error-handling system:
| Mechanism | Use Case | Recoverable? |
|---|---|---|
try/catch |
Runtime errors, external failures | Yes |
throw |
Explicitly signal an error from your code | Yes (caught by try/catch) |
panic() |
Invariant violations, missing critical config | No |
context() / with_context() |
Annotating errors as they propagate | N/A (enhances either) |
Result maps {ok, value/error} |
Expected, structured failures (map convention) | Yes |
Ok(value) / Err(msg) |
Expected, structured failures (native) | Yes |
Some(value) / None |
Absence of a value, safe access | N/A |
You learned:
- try/catch and throw -- catching runtime errors and explicitly throwing your own.
- panic() -- halting execution for truly unrecoverable situations.
- context() and with_context() -- annotating errors with descriptive context as they propagate through the call stack.
- The Result pattern (map-based) -- returning
{ok: true, value}or{ok: false, error}maps for expected failures, with helper functions likeresult_map,result_and_then, andresult_unwrap_orfor clean chaining. - The native Result type -- using
Ok(value)andErr(msg)with method chaining via.map(),.and_then(),.unwrap(), and.unwrap_or(). - The Option type -- using
Some(value)andNonewith the full API:is_some(),is_none(),unwrap(),unwrap_or(),map(),and_then(),or_else(), andto_list()for clean, chainable pipelines. Safe access methods likelist.first(),list.last(),list.get(i), andmap.get(key)return Options automatically. - Structured error types -- using
kind-based error constructors for consistent error handling across modules. - Graceful degradation -- fallback agents, retry with backoff, partial results, and sensible defaults.
Remember the pilot's checklist from the start of this chapter? You now have your own checklist: try/catch for unexpected turbulence, Result for anticipated bumps, Option for "maybe there is a runway, maybe there is not," and panic() for the ejection seat you hope you never need. Your agents should never leave a user staring at a raw stack trace.
In the next chapter, you will learn how to organize your Neam code into modules and packages, enabling reuse across projects and teams.
Exercises #
Exercise 8.1: Safe List Access
Write a function safe_get(list, index) that uses the Option type to safely access
a list element. It should return Some(list[index]) if the index is valid, or None
if it is out of bounds. Compare your implementation with the built-in list.get(index)
method. Then chain .map() to double the value and .unwrap_or(0) to provide a default.
Test with valid and invalid indices.
Exercise 8.2: Cascading Fallback
Write a function ask_cascade(agents, query) that takes a list of agent references and a
query string. It tries each agent in order. If one fails, it tries the next. If all fail,
it returns an error result containing all the individual error messages. Test with at least
three agents.
Exercise 8.3: Validation Pipeline
Build a validation pipeline for an agent configuration map. The configuration must have:
- "provider" -- must be one of "openai", "anthropic", "ollama", "gemini".
- "model" -- must be a non-empty string.
- "temperature" -- optional, but if present, must be between 0.0 and 2.0.
- "max_tokens" -- optional, but if present, must be a positive integer.
Return a result map. If validation fails, the error should contain a list of all validation errors found (not just the first one).
Exercise 8.4: Error Context Chain
Write three functions that call each other: read_data() calls parse_data(), which
calls validate_data(). Each function adds its own context to any error using
context(). Deliberately introduce an error in validate_data() and verify that the
final error message contains all three context layers.
Exercise 8.5: Partial Batch Processor
Write a function process_numbers(numbers) that takes a list of values. For each value,
attempt to compute its square root (you can simulate this with a check for negative
numbers). Collect successful results and errors separately. At the end, emit a summary:
how many succeeded, how many failed, and the list of errors with their original values.
Test with a list like [16, -4, 25, 0, -9, 100].
Exercise 8.6: Option Chain with Full API
Write a function lookup_email(users, name) that searches a list of user maps for a
given name and returns their email address. Use the Option type throughout:
find_user(users, name)should returnSome(user)if found,Noneif not.- Chain
.map()calls to extract the"email"field from the found user. - Use
.unwrap_or("unknown@example.com")to provide a default.
Test with a users list where some names exist and some do not. Then extend the chain
to also look up the user's "department" field (which may be missing even for existing
users). Use .and_then() to handle the case where the field itself is nil by
returning None. Use .or_else() to provide a fallback department. Finally, use
.to_list() to collect all found departments into a flat list.
Exercise 8.7: Result Pipeline Build a data pipeline using the native Result type that:
- Takes a raw string input.
- Parses it into a number (returning
Errif parsing fails). - Validates that the number is positive (returning
Errif not). - Doubles the number.
Use Ok/Err and chain the steps with .and_then() and .map(). Test your pipeline
with the inputs "42", "-7", "abc", and "0". For each input, emit the final
value using .unwrap_or(0) and also emit whether the result .is_ok() or .is_err().