Without Exception: How Neander Programs Fail
DEV Community

Without Exception: How Neander Programs Fail

Neander has no exceptions. No try, no catch, no finally. A call to one of the host application's APIs returns something closer to Rust's Result: either the answer, or the reason there is no answer. In place of a catch block there is one type marker, three operators, and a guarantee that every submission comes back in the same shape no matter what happened.

Last time the foundational series closed with isolation. This is the first of two encores, and it takes the subject that came up in nearly every entry without ever being laid out in full: what happens when something goes wrong. There are two answers, because there are two audiences. An error is a value while the program runs, and a verdict once it has stopped. The two are made of the same parts, on purpose.

The failable type

Every call returns a failable type, written T!. It carries either a value of type T or an error with a code, a message, and the name of the function that produced it. T! is the mirror of the nullable type T?. Same shape, different question: one asks whether a value is there at all, the other asks whether obtaining it worked.

The mirroring runs deeper than the notation, because the same three operators serve both types. A failure gets no unwrapping vocabulary of its own. Those three are =?, ?? and is:

// narrow, or throw the error out of the enclosing block
let order: Order =? call orders.get(id: 42)

// or substitute a default
let order: Order = call orders.get(id: 42) ?? emptyOrder

// or inspect it and decide
let result: Order! = call orders.get(id: 42)
if result is error {
  if errorCode(result) != 404 { throw result }
  return emptyOrder
}

A standalone call statement, one without a let, narrows implicitly: the error is thrown and the success value is discarded.

One property does the heavy lifting throughout the rest of this post: T! originates only from a call. No expression picks up a ! along the way, and no widening rule introduces one. The marker means exactly one thing, namely that the value came from outside the program. Which is also why it appears nowhere else. Division by zero, an index out of bounds, a missing map key: none of them change an expression's type. total / count is an int and never an int!, and the agent guards it with if count != 0 rather than with the type system. That is inconsistent, and it is deliberate. Making runtime errors failable would put a ! on very nearly every expression in the language, where it would come to mean "something could go wrong here", which is true of all code everywhere and therefore worth nothing.

There is no catch

Errors propagate by throwing, and throwing is control flow, not a value. =? throws from the immediately enclosing block. Inside an each or a repeat, that ends the iteration and hands the error to the scope above. Nothing catches it, because the language has no construct that catches. A thrown error cannot be bound to a failable type and recovered, since failable values come from calls and never from throws.

An error that no branch handled keeps rising until it leaves main, and at that point the program is over. Which is why main -> Order! is a type error, rejected before the program runs. An error does not leave main through the return type. It leaves by being thrown. main -> [Order!] is perfectly valid, though. The return type there is a list, and a list is not failable. Only its elements are. That distinction looks like a technicality, and most of the design rests on it.

Errors are data

The type checker refuses to let a T! reach a position that expects a T. It does not, however, insist that every T! be resolved. A failable value can be stored in a record field, held in a list, checked with is, or returned as part of a composite result, all without ever being narrowed. The rule binds at boundaries; it is not ceremony on every line. That is what turns partial failure into something a program can express.

Consider fifty payment initiations, written the strict way:

main -> [PaymentRef] {
  let invoices: [Invoice] =? call invoices.list(status: "approved", limit: 50)
  let refs: [PaymentRef] =
    each invoices as inv -> PaymentRef {
      let ref: PaymentRef =? call payments.initiate(invoiceId: inv.id, amount: inv.total)
      yield ref
    }
  return refs
}

The first failed initiation throws out of the each, then out of main, and the other forty-nine outcomes are gone. Whether the eleventh invoice was paid is not in the response.

Now remove one operator:

main -> [PaymentRef!] {
  let invoices: [Invoice] =? call invoices.list(status: "approved", limit: 50)
  let refs: [PaymentRef!] =
    each invoices as inv -> PaymentRef! {
      yield call payments.initiate(invoiceId: inv.id, amount: inv.total)
    }
  return refs
}

Same program, one =? apart. This one runs to the end and returns all fifty outcomes, successes and failures side by side. Each element serializes with its own discriminator:

{ "ok": true, "value": { "id": 8801, "invoiceId": 42, "amount": "150.00" } }
{ "ok": false, "error": { "code": 409, "message": "vendor account frozen", "source": "payments.initiate" } }

The key is deliberately ok rather than success, to keep it distinct from the top-level field of the response envelope. More on that field in a moment.

This is the case that exceptions make genuinely awkward. An exception unwinds, so producing a partial-failure report out of one takes a try/catch around every iteration plus a hand-rolled accumulator, written correctly every time. Here it is the absence of an operator. [PaymentRef!] serializes into the response envelope with one ok flag per element, so the agent receives a report rather than a stack trace, and knows what to retry without submitting a second program to find out.

One envelope, every time

Once an error leaves main, it stops being a value. It becomes the runtime's verdict on the submission. Every submission produces the same response envelope that already carried discovery results back to the agent: success, result, meta. When success is false, the response carries a Failure, and every Failure falls into one of three categories, divided by when it happened and whose fault it was, not by how bad it was.

  • A Flaw is a defect in the program, found before execution. The program never ran. There are seven kinds, from parse_error and type_error through validation_error to the static caps of the budget system such as program_too_large. A Flaw reports line, column, a remediation hint, and the limit that was exceeded where one applies. Its meta block is empty, because nothing executed.

  • An Error is a failure during execution, and it means the program ran and something got out. It reports a code, a message, and a source, and the source alone tells the three origins apart: a qualified function name for a failed API call, "runtime" for one of the twelve runtime error codes such as division by zero or index out of bounds, and "main" for an error the program threw deliberately.

  • An Abort is imposed from outside. Either a budget was exhausted, in which case the envelope names which one along with the limit and the amount consumed, or the runtime hit a condition it could not recover from. Nothing is wrong with the program. Its execution was simply ended.

The split earns its keep because each category answers the agent's next question. A Flaw means rewrite the program, and line, column and hint make that close to mechanical. An Error means the program was right and the world was not. An Abort means do not resubmit the same thing, ask for less. The taxonomy tells the agent what to do next. It is not a severity scale.

The seam

An Error envelope carries code, message and source. Those are precisely the values errorCode(), errorMessage() and errorSource() would have returned had the program handled the error itself. Same error, one altitude up.

The two halves of this post are one object seen from two positions. Exactly one thing crosses in the other direction. The per-call timeout, which the budget post left open, is a runtime error delivered inside a T!. That does not contradict the rule that runtime errors never add a ! to a type: the ! was already there, because call is failable to begin with. A timed-out call is one more failure that call can carry, handled with =? or ?? like any other. Only errorSource() returning "runtime" reveals that the runtime produced it rather than the API.

Grotto's take on failure

Each category is produced at a different point in Grotto's dispatcher-worker split. Flaws come from the parser and the validator, before main is invoked at all. Errors come from the interpreter. Aborts come from two places: the cooperative budget checks inside the worker, and the dispatcher's own clock when it has to terminate a worker that stopped checking in. Provider contract violations surface as infrastructure aborts, which is how a bug in host-supplied code stays out of the program's failure model entirely.

The guarantee that matters is the dispatcher's: exactly one envelope per submission, including for a worker it had to kill mid-instruction. A submission never produces silence.

Next from the Grotto

One encore left. The type system deserves a closer look on its own terms: structural rather than nominal, recursive, and reaching all the way down to integers and decimals of arbitrary precision. In the meantime, read the Neander spec, embed Grotto in your own app, and let me know how it failed.

Comments

No comments yet. Start the discussion.