Diagnostic guards and function-scoped early returns

RiX separates three activities: deciding whether a function variant applies, checking whether a computation can proceed, and performing that computation. An expected rejection often deserves a useful result rather than an exception or a different algorithm. Return guards express that directly.

The two operators

Expression Trigger Otherwise
check ?_> result Decided negative/null _ Preserve the check’s value
check ??> result Undecided ? Preserve the check’s value

On a trigger, evaluate result and immediately return it from the current function call. Do not execute later prep entries or body statements. The result can be any RiX value, including a diagnostic map, _, or ?.

Both operators require an active function call whenever they are evaluated, even if the selected return would not fire. They are valid in function prep, default-parameter expressions, and function bodies, including nested blocks, cases, loops, and async scopes. They do not return from the surrounding block; RiX’s existing block/loop break syntax remains separate. Defining a function containing these operators is valid at top level; evaluating one at top level is an error.

Tutorial: separate validation from calculation

CoursePower(x,n) ?!- [
    n ? :Integer ?_> {= status=:unsupported,reason=:integerExponentRequired },
    n >= 0 ?_> {= status=:unsupported,reason=:negativeExponent },
    n <= 8 ?_> {= status=:unsupported,reason=:degreeBudgetExceeded }
] -> {= status=:complete,value=x^n };

CoursePower(2,3)[:value] ##@ == 8;
CoursePower(2,-1)[:reason] ##@ == :negativeExponent;
CoursePower(2,9)[:reason] ##@ == :degreeBudgetExceeded;

The mathematical body contains only the successful computation. The ?!- mode keeps unexpected errors visible, while the annotated checks return expected unsupported results. This pattern is used in the CAS trig-power integration helper: its applicability, exponent budget, and affine-argument checks precede construction of the antiderivative.

Tutorial: keep negative and undecided separate

Choose(decision) ?!- [
    decision ?_> {= status=:rejected } ??> {= status=:unresolved }
] -> {= status=:accepted };

Choose(_)[:status] ##@ == :rejected;
Choose(?)[:status] ##@ == :unresolved;
Choose(1)[:status] ##@ == :accepted;
Choose(0)[:status] ##@ == :accepted;

As elsewhere in RiX, zero is truthy, not false. Numeric requirements must be comparisons such as n > 0. ?_> must not classify uncertainty as a negative result. If neither operator handles the current decision, prep keeps its existing policy: ?-/?!- propagate undecided, ??- allows undecided fallthrough, and ??!- raises an error for undecided.

In a body, an untriggered guard is just its original value. A standalone decision ?_> rejected; does not stop the body when decision is ?. Handle both states explicitly when proceeding requires a decided positive.

Tutorial: return through a nested block

Describe(x) -> {;
    x ? :Integer ?_> :notInteger;
    {;
        @x >= 0 ?_> :negative;
    };
    :nonnegativeInteger;
};

Describe(:word) ##@ == :notInteger;
Describe(-2) ##@ == :negative;
Describe(3) ##@ == :nonnegativeInteger;

The negative path exits Describe, not only its inner block. Normal scope and capture rules still apply: the inner block needs @x. The return operator itself introduces no new scope.

Tutorial: retain diagnostic setup bindings

RequirePositive(x) ?!- [
    candidate = x ?_> {= status=:missing,value=candidate },
    candidate > 0 ?_> {= status=:rejected,value=candidate }
] -> {= status=:accepted,value=candidate };

RequirePositive(_)[:status] ##@ == :missing;
RequirePositive(-3)[:value] ##@ == -3;
RequirePositive(4)[:value] ##@ == 4;

The binding runs before its return check, so the diagnostic can inspect it. The left side is evaluated once; only a triggered payload is evaluated.

Tutorial: a handled rejection is final in dispatch

Select = [
    (x) ?- [x ? :Integer, x >= 0 ?_> :negative] -> x^2,
    (x) -> :differentInputFamily
];
Select(3) ##@ == 9;
Select(-3) ##@ == :negative;
Select(:word) ##@ == :differentInputFamily;

The unannotated type check says “not this variant”. The annotated sign check says “this call has a result”. Returning _ or ? explicitly is also final; dispatch never guesses whether to continue by inspecting the returned value.

Call ownership and cleanup

Each recursive invocation has its own return target. A callback’s guard returns from that callback, not its caller. For example:

Transform() -> {;
    results = [1,_,?].Map((x)->{; x ?_> 20 ??> 30; x+1; });
    results[1]+results[2]+results[3];
};
Transform() ##@ == 52;

Call arguments are evaluated by the caller before entering the callee. Thus Other(_ ?_> diagnostic) inside F returns from F; it never calls Other. Defaults evaluated during parameter binding belong to the new callee instead. Evaluating deferred code does not capture a permanent return target: it uses the active call when the code executes. A finished activation cannot receive a later return.

Returns unwind intervening scopes and run registered ##_ cleanup. Cleanup errors remain errors; they are not discarded as successful returns. Return control is not an ordinary runtime error and is not swallowed by soft prepared trials or counted as a successful .TestError assertion.

Async evaluation awaits both the check and the selected payload. Nested async scopes unwind and perform their existing cancellation/cleanup before the function returns. Work already started concurrently is not rolled back; do not use early return as a transaction or as a replacement for cancellation.

Precedence and errors

Return guards bind below assignment, logical operators, comparisons, and postfix checks. Consequently:

bound = expression ?_> diagnostic
    means (bound = expression) ?_> diagnostic

a && b ?_> diagnostic
    means (a && b) ?_> diagnostic

check ?_> rejected ??> unresolved
    means (check ?_> rejected) ??> unresolved

The operators associate left-to-right. Parenthesize a nested return expression when it belongs inside a payload. A semicolon ends a body guard; a comma ends a prep entry. Parentheses are also helpful when mixing return guards with ordinary ?:, ?_, and ?? conditional branches.

An exception in an unannotated/check expression retains the surrounding prep mode’s existing behavior. An exception in a selected return payload propagates even through soft prep: a broken diagnostic is not a dispatch miss. Neither operator turns an error into an unsupported result automatically.

1 ?_> :unused;

Implementation contract

The parser emits ReturnGuard(condition, decision, value). Lowering produces GUARD_RETURN({decision}, DEFER(condition), DEFER(value)), in the Logic capability group. Evaluation checks the dynamic call target first, evaluates the condition once, and either preserves it or transfers a returned value to that activation. Sync and async invocation boundaries treat that transfer as a matched result independently of the value’s truth state.

Existing source without these operators retains its prep/dispatch semantics.

Back to top