Docs contents

Absent values, failures, and panics

Contents · Guide · Syntax · Functions

Choose what the caller needs to know before choosing a type. These three situations differ:

Situation Nagi form Example
A value may be absent T? (Option) An optional age is None
An operation returns success or failure Result[T, E] Number conversion returns Err
An abnormal failure outside ordinary business error handling panic An out-of-bounds list index

None does not explain a processing failure, and Err is a returned value rather than a Python exception. Compilation errors and build environment failures are separate from all three.

On this page

Handle an absent value

To use a default when no number is present, Python can check for None:

def value_or(value, fallback):
    if value is None:
        return fallback
    return value

Nagi marks possible absence with i64? and extracts the value by matching both cases. This is a complete program:

def value_or(value: i64?, fallback: i64) -> i64:
    match value:
        case Some(number):
            return number
        case None:
            return fallback

def main():
    print(value_or(some(42), 0))
    print(value_or(None, 0))

Output: 42, 0. Construct a present value with lowercase some(...); extract it with uppercase Some(...). None needs no payload or parentheses. The extracted number is an i64 available inside its case.

Using value + 1 directly in that function fails check: i64? is not an i64. Perform the operation on number inside Some. Python-style if value is not None: narrowing and a general unwrap API are unsupported. Handle absence before using the inner value; see types and match syntax.

def add_one(value: i64?) -> i64:
    return value + 1  # check rejects using i64? as an i64

Match both Some and None before doing the calculation. This example treats None as zero and prints 42, then 0.

def add_one(value: i64?) -> i64:
    match value:
        case Some(number):
            return number + 1
        case None:
            return 0

def main():
    print(add_one(some(41)))
    print(add_one(None))

Result[T?, E] combines two questions: did the operation succeed, and, if so, is there a value? Its Ok(None) differs from Err(problem).

Propagate failure to the caller

To validate an input id and leave failure handling to the caller, Python can let a ValueError from int(text) propagate without catching it. Nagi instead returns a Result, as in this function definition fragment:

def read_id(text: view[str]) -> Result[i64, Error]:
    id = try parse_i64(text)
    if id < 1:
        return error("id must be positive")
    return ok(id)

Reading "2" returns success containing 2; "oops" returns a conversion failure, and "0" returns the failure you constructed. Result[T, E] holds success T or failure E; use built-in Error or your own class or enum for E.

try extracts the success value or returns the failure to the caller. It is not a catching block like Python’s try/except. Using it in a function returning plain i64 fails check. Return Result as above, or recover with match, described next. The called function and your function must use the same error type E. Use try to propagate Err.

def read_id(text: view[str]) -> i64:
    id = try parse_i64(text)  # check rejects this: the function cannot return Err
    return id

To propagate the failure, give this function the return type Result[i64, Error] and use try, as in the corrected read_id above. To recover here and return a plain number, match both Ok and Err instead; see result.nagi.

For async operations, write value = try await operation(...). The value of try is T; to return a Result, write return ok(try operation(...)). Convert a different error type with match or std.result.map_error, described below.

Separate success and failure

To return a default for invalid input, Python can recover with try/except ValueError. In Nagi, use match to branch on the returned value and return an ordinary i64. This complete program is in result.nagi.

def number_or(text: str, fallback: i64) -> i64:
    match parse_i64(view(text)):
        case Ok(number):
            return number
        case Err(problem):
            print(error_kind(problem))
            message = error_message(problem)
            assert_true(len(view(message)) > 0)
            return fallback

def main():
    print(number_or("21", 0))
    print(number_or("oops", -1))

Save it as result.nagi and run it from that folder:

nagic run result.nagi

Output: 21, invalid, -1. Patterns begin with uppercase Ok and Err; constructors use lowercase ok(...) and error(...).

Writing only Ok and omitting failure fails check. Provide an Err case, and use _ only when intentionally discarding its value. Handle both cases with match for local recovery.

  • Write exactly one case Ok(...) and one case Err(...), in either order.
  • Bound names receive the success or failure type. Use case Err(_): for an unused value.
  • Names are available only within their case. Reusing an outer variable's name is rejected.
  • Matching consumes the Result, moving owned payloads such as strings. The origin of a borrowed payload remains checked as borrowed within the case.
  • When both cases return, the function is checked as returning a value on every path.

Matching is a statement over Result, Option, or enums. For Option, write both case Some(value): and case None:. Match expressions, guards, nested patterns, and case _ are unsupported. Low supports the same branches.

Define your own error type

An enum groups failure kinds and the data each kind needs.

enum QuantityError:
    InvalidNumber
    OutOfRange(minimum: i64)

def positive(value: i64) -> Result[i64, QuantityError]:
    if value < 1:
        return fail(QuantityError.OutOfRange(minimum=1))
    return ok(value)

def fallback(problem: QuantityError) -> i64:
    match problem:
        case QuantityError.InvalidNumber:
            return 0
        case QuantityError.OutOfRange(minimum):
            return minimum

Construct a unit variant with QuantityError.InvalidNumber. For payload variants, use positional arguments such as QuantityError.OutOfRange(1) or named arguments. Match every variant exactly once. Bind payloads in field order, using _ for unused values. Unknown variants, invalid arguments, and missing or duplicate cases fail check.

A class can also be the failure value in Result[T, MyError]. For example, a StorageError class can have a cause: Error field; fail(StorageError(cause=problem)) preserves the original cause. Error classes and enums do not need JSON or database conversions. JSON conversion of enums or classes containing built-in Error is unsupported.

The typed-error CLI example covers explicit conversion from built-in Error, propagation with the same error type, and enum matching.

Convert an error type

map_error from std.result converts an error while preserving the success value.

import std.result as result

enum InputError:
    InvalidNumber(cause: Error)

def invalid_number(cause: Error) -> InputError:
    return InputError.InvalidNumber(cause)

def read_number(text: view[str]) -> Result[i64, InputError]:
    return result.map_error(parse_i64(text), invalid_number)

map_error takes a Result[T, E] and a synchronous function fn[E, F], returning Result[T, F]. Type arguments are inferred. Pass a named function or a local variable holding that synchronous function. The mapper is not called for Ok; it is called exactly once for Err.

It consumes the input Result, moving its success or failure value. map_error itself does not clone or allocate, though the mapper may allocate. Borrowed success values in T retain their original lifetime checks. An output error type F containing view is currently unsupported.

try does not automatically convert error types. A function using try on the converted Result must return the same error type F. The stock report CLI converts the Error from JSON decoding into InventoryError this way.

Inspect, construct, and return Errors

Form Meaning
error("reason") Creates an input failure with kind invalid
not_found("reason") Creates a missing-target failure with kind not_found
internal_error("reason") Creates an internal failure with kind internal
error_kind(problem) Returns the kind name as an owned string
error_message(problem) Returns an owned copy of the message
return fail(problem) Moves Error, preserving its original kind and message

The success type comes from the return or variable context. fail(problem) moves a built-in Error, class, or enum failure value. Without context its type is Result[unit, E]. error_kind and error_message apply only to built-in Error and do not consume it. Messages can contain internal information such as database details.

Traditional HTTP handlers such as @get return Result[..., Error]. Available from Nagi 0.1.8, std.http.server accepts a custom error type E, with an App or route mapper converting it to Response. Built-in Error kinds in traditional handlers map as follows:

Kind HTTP status
invalid 400
not_found 404
busy 503
database/internal 500

Database/internal 500 responses contain {"error":"internal error"}; details go to server logs. A successful None in Result[T?, Error] also becomes 404.

Try the Result API example for invalid input, missing data, database failure, and recovery. Its Python smoke script sends HTTP requests to an already running server and checks responses.

Checks and panics

The checker rejects a Result discarded as an expression, including values with outer owned wrappers. It also rejects async calls that are not awaited. However, it does not detect unused Results assigned to variables, and try and match do not support owned[Result[...]]. A successful check does not prove that every error is handled.

Result failures differ from panics. Out-of-bounds array access and integer / or % whose divisor becomes zero at runtime panic rather than return Result. There is no general exception syntax equivalent to Python's raise/except.

Nagi 0.1.10 rejects a literal zero divisor for integer / or % in both High and Low. The constant validation targeted for Nagi 0.1.11 also rejects 1 / (1 - 1), zero values established by scalar bindings within the same function, and signed minimum division or remainder by -1. New constant diagnostics run after successful name and type checking. Check the official release record to confirm availability.

Constant failures in unreachable branches are checked too, so previously buildable dead branches can now be rejected. Overflow from +, -, and * still panics in debug builds and wraps in release. Profile-dependent values, calls, extern functions, fields, and indexing are not propagated as known constants. This does not detect every runtime zero divisor.

In Nagi 0.1.10, HTTP servers convert unwinding handler panics before the response starts into a generic 500 and close the connection. A 500 does not roll back database or shared state changes. See the HTTP reference for the recovery limits.

Scopes check child results after the scope body finishes and detect panics at that point. In std.actor, available from Nagi 0.1.8, a business error E inside Turn keeps the next state and becomes the reply. A handler's own Error or panic invokes the Supervisor's restart policy. call returns Result[Result[R, E], CallError], separating business errors from not-ready, stopped, timeout, and other call failures. The sample also maps them to HTTP responses.

The older supervisor_demo remains a fixed-worker restart test API. Neither mechanism recovers from memory corruption or process aborts.

Diagnostics show the filename, line, relevant source text, and reason. Mapped build errors show the Nagi or Low statement or definition line and related notes. Use build/run --rust-diagnostics for generated Rust details. Rust suggestions apply to Rust. Handwritten Rust and unmapped diagnostics remain visible at their Rust locations. Precise columns and mappings for every Rust diagnostic are not implemented. Definition navigation also uses original columns.

See Result matching, custom error types, map_error, integer zero-division checks, scopes, and actors for implementation and tests.

Nagi 0.2.0 · DocsPage source