Programming Neam
📖 15 min read

Chapter 8: Error Handling #

💠 Why This Matters

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:

  1. try/catch for catching and handling exceptions at runtime.
  2. throw for explicitly raising errors from your own code.
  3. panic() for signaling unrecoverable errors that should halt execution.
  4. context() and with_context() for annotating errors with additional information as they propagate up the call stack.
  5. Result maps (a convention using {ok: true/false, ...}) for functions that can fail without throwing exceptions, plus the native Ok/Err Result type.
  6. The Option type (Some/None) for representing the presence or absence of a value, with a full API including map(), and_then(), or_else(), and to_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:

neam
{
  try {
    let result = 10 / 0;
    emit "This line is never reached.";
  } catch (err) {
    emit "Caught an error: " + str(err);
  }
}

Output:

text
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:

neam
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:

text
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:

neam
{
  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:

text
Outer try begins.
Inner try begins.
Inner catch: cannot add nil and number
Outer try continues after inner catch.
🎯 Try It Yourself

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:

neam
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:

text
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:

neam
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:

text
Error kind: MissingField
Missing field: provider
Message: Required field is missing
📝 throw vs. panic()

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:

neam
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:

text
Model: gpt-4o
PANIC: Missing required configuration: provider
⚠️ Warning

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:

neam
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:

text
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:

neam
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:

text
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.

Call Stack (deepest first):
read_file()
load_agent_config
initialize_agent
main { }

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:

neam
// 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:

neam
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 #

neam
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:

text
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:

neam
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:

text
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:

neam
// 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:

neam
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:

text
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:

neam
{
  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).

neam
{
  // 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():

neam
{
  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:

text
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:

neam
{
  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:

neam
{
  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:

Use .and_then() when your transformation might itself produce None:

neam
{
  // 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:

neam
{
  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:

neam
{
  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:

neam
{
  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:

neam
{
  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:

neam
{
  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
🎯 Try It Yourself

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 #

neam
{
  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:

neam
{
  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)
💡 Pro Tip

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:

neam
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:

neam
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:

text
[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:

neam
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:

neam
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:

text
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:

neam
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:

neam
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:

neam
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:

neam
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:

text
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:

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:

neam
// 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:

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:

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:

  1. Takes a raw string input.
  2. Parses it into a number (returning Err if parsing fails).
  3. Validates that the number is positive (returning Err if not).
  4. 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().

Start typing to search...