Skip to content

Error Handling

Nilakan represents selected recoverable failures as explicit err values. Only operations whose contracts register a recoverable failure may produce one. Syntax, type, arity, raw access, destructuring, mutation, resource, hashability, invariant, and other fatal diagnostics are never implicitly converted into values.

The err type is a scalar value containing a name, message, diagnostic code, and runtime creation origin. Nilakan source constructs one with err(name, message, code):

failure: err = err("ValidationError", "missing account", "APP0001")
print(failure)

A binding or function contract must explicitly accept a direct error. A root err or a root union containing err accepts one:

failure: err = err("ValidationError", "missing account", "APP0001")
outcome: num | err = failure
def identity(value: num | err) -> num | err:
return value

The root policy is shallow. A nested err permits errors only at that nested position:

items: list[num | err] = [1, err("Missing", "no value", "APP0002")]

list[num] rejects an error in an element, while list[num] | err accepts either an entirely successful number list or a direct error. An unannotated binding and a root contract without err reject results that are known to be, or may be, direct errors. Recover or declare the union before assigning, returning, destructuring, or discarding such a result.

Annotations persist across later assignment, destructuring, subscript assignment, primed updates, and commit-loop state. The enclosing contract always wins over a storage operation’s generic ability to retain values:

Intentional failure — NLK2011
values: list[num] = [1]
values = values.set(0, err("Failure", "bad value", "APP0003")) # NLK2011

Sets and dictionary keys are always deeply hashable and error-free. Ordinary container values can store errors when their enclosing contract permits them.

Raw indexing remains fatal when an index or key is missing. Use get when absence is an expected value-level outcome:

first: num | err = [10, 20].get(0)
missing: num | err = [10, 20].get(10)
letter: str | err = "Nilakan".get(1)
entry: num | err = {"ready": 1}.get("missing")
fallback: num = {"ready": 1}.get("missing", 0)

str.get, list.get, and tuple.get return IndexError/NLK4004 on a miss. One-argument dict.get returns KeyError/NLK4003; two-argument dict.get returns its default without a missing-key error. A successfully extracted stored error keeps its identity and creation origin.

Other registered value failures include the implemented ord, chr, string search and validation, list and tuple search, list and set removal/pop, and dictionary pop/removal operations. Each operation contract defines its own recoverable tags. Nilakan does not use a blanket runtime-exception conversion rule.

left catch fallback handles only a direct error result from left. Evaluation is lazy:

  • a successful left value is returned and the fallback is not evaluated;
  • an error-only left result evaluates only the fallback;
  • a left result that may be success or error preserves the success path and evaluates the fallback for the error path.
value = [10, 20].get(8) catch 0

catch consumes the left error. An error returned normally by the fallback remains an ordinary result error. Propagation paths bypass an outer catch; they are not converted back into the left expression’s direct-error branch. A catch whose left side has no normal direct-error branch is NLK2010 even when that side can propagate, so (try operation()) catch fallback is rejected rather than acting like exception recovery.

Using catch on a statically success-only expression is NLK2010. A statically unreachable left expression remains unreachable without evaluating the fallback or producing that diagnostic. catch is right-associative, so a catch b catch c parses as a catch (b catch c).

try operand handles only a direct error in the operand’s normal result:

  • success continues unchanged;
  • a possible error is removed from the normal value and added to the current function’s propagated result;
  • an error-only result stops that path and propagates the error.

Existing propagation is preserved and bypasses an outer try. Applying try where no direct error can be handled is NLK2010. Top-level try is also NLK2010. Inside a function, try is allowed only when the root return contract accepts a direct error.

Propagation belongs to one function invocation. At its boundary, accepted propagated errors are reified as direct return results. Use try when successful execution has more work to do and an error should skip that work:

def first_plus_one(values: list[num]) -> num | err:
first = try values.get(0)
return first + 1
def first_plus_two(values: list[num]) -> num | err:
first = try first_plus_one(values)
return first + 1
failure: num | err = first_plus_two([])

This program has two separate propagation events. The error returned from first_plus_one is a normal result at the caller boundary, and the caller’s try propagates it again. In contrast, return values.get(0) needs no try: directly returning an accepted error result has no later work to skip.

Arguments are evaluated in the caller. Omitted defaults are evaluated sequentially in the callee after its frame is created, so a try in a default exits the function that owns the default. Supplying that parameter skips both the default evaluation and its effect. Parameter and default values follow the same annotation-derived error policy.

is_err accepts optional keyword-only, exact, case-sensitive filters:

Expression forms — value must already be bound
is_err(value)
is_err(value, name="IndexError")
is_err(value, code="NLK4004")
is_err(value, name="IndexError", code="NLK4004")

When both filters are present they are combined with AND. A matching branch narrows a stable bare variable to the selected error tags; the false branch excludes known matching tags. not reverses the facts, and each elif receives the accumulated false facts. Writing the variable invalidates its prior narrowing.

err_name, err_message, and err_code require a value known to be a direct error. Narrow a success-or-error union with is_err before inspecting it. The inspectors’ string results can be compared normally.

Errors cannot participate in equality, inequality, membership comparison, collection search, count, index, or remove. This also applies when structural traversal reaches an error inside a container. A known violation is NLK2013; an unknown shape is checked at runtime and rejected as NLK4006.

Errors are never compared by name, message, code, origin, or container position. Use the inspectors or filtered is_err when matching is intentional.

Eager expression children are evaluated left to right. Once a child propagates, later eager children do not transfer runtime effects. and, or, conditional expressions, and catch fallbacks evaluate only the branch required by their left operand or condition. Statically impossible branches are excluded.

In a commit loop, propagation abandons the round’s staged next-state writes. Flow analysis includes the zero-iteration path and joins continuing branch state while keeping propagated errors separate.

The public diagnostic name for the static rules remains StaticError.

| Code | Meaning | | --------- | -------------------------------------------------------------- | | NLK2009 | Direct error reached a generic rejecting or discarded position | | NLK2010 | Invalid try or catch | | NLK2011 | Root or nested type-contract mismatch | | NLK2013 | Direct or contained error comparison | | NLK2014 | Operation requirement violation, including error inspection | | NLK4006 | Runtime rejection of an error in an unknown shape | | NLK5999 | Interpreter invariant failure |

Known recursive type and operation violations are reported statically. Unknown shapes defer to the authoritative recursive runtime checks. NLK2011 identifies the failing structural path and the expected and inferred types when known. Diagnostics for raw access, rejected direct errors, comparison, and error inspection include recovery guidance.

Every error value carries an enumerable structured creation origin. Copying, deep copying, structured cloning, worker transfer, safe extraction, CLI execution, and playground execution preserve it. Runtime NLK4006 diagnostics keep creation and rejection stacks separately, innermost first, retaining up to 16 frames and rendering up to five from each stack. Other fatal runtime diagnostics render an innermost-first call stack when execution crossed a function boundary.

Printing, retaining, or validly returning an error is successful execution. The CLI exits with status 0, and the playground reports Completed. Only an invalid use or fatal diagnostic fails the run.