Contracts In progress
In Progress Feature
requires, ensures, and invariant clauses, with the debug and static
modifiers, are checked. Unmarked and debug clauses compile to runtime
assertions; static clauses are specifications that are never run. The compiler
rejects clauses in positions that are not yet supported (such as methods without
a body) rather than ignoring them. Static verification is planned.
A contract clause attaches a boolean condition to a callable declaration, arrow
expression, or loop statement. Unmarked clauses run in every build; clauses
prefixed with debug run in zena test and --debug (-g) builds and are
omitted from ordinary builds. When an active clause's condition evaluates to
false, it throws a ContractError.
Clause syntax and placement ​
Grammar ​
FunctionContracts := (RequiresClause | EnsuresClause)*
Modifier := 'debug' | 'static'
RequiresClause := Modifier? 'requires' '(' Expression ')'
EnsuresClause := Modifier? 'ensures' '(' Expression ')'
LoopContracts := InvariantClause*
InvariantClause := Modifier? 'invariant' '(' Expression ')'
Callable positions ​
On functions, arrow functions, methods, and constructors, requires and
ensures clauses appear after the parameter list and optional return type
annotation, and before the body, =>, or constructor initializer list:
import {div} from 'zena:math';
// Top-level function declaration
function exactHalf(n: i32): i32
requires (n % 2 == 0)
ensures (result * 2 == n)
{
return div(n, 2);
}
// Arrow function (clauses precede `=>`)
let abs = (x: i32): i32 ensures (result >= 0) => if (x >= 0) x else -x;
class Counter {
var count: i32;
// Constructor (clauses precede `: initializerList` and body)
new(start: i32)
requires (start >= 0)
ensures (this.count == start)
: count = start {}
// Instance method
add(delta: i32): i32
requires (delta > 0)
ensures (this.count == old(this.count) + delta)
ensures (result == this.count)
{
this.count += delta;
return this.count;
}
}
A function may carry any number of requires and ensures clauses in any order.
Clauses are not accepted on declare function declarations, getters or setters,
or interface constructors.
Contextual keywords ​
requires, ensures, invariant, and debug are contextual keywords:
- In signature position (after
)or a return type),requires,ensures,debug requires, anddebug ensuresbegin contract clauses. Everywhere else they remain ordinary identifiers. - After a
while,for, orfor-inheader,invariant (...)ordebug invariant (...)begins a loop contract clause only when followed by{or another loop clause. A statement likewhile (ready) invariant(x);parses as a loop whose single-statement body calls a function namedinvariant. Consequently, any loop withinvariantclauses must use a{ ... }block body. staticis a keyword everywhere. Directly beforerequires,ensures, orinvariantin the positions above it is a clause's modifier; astaticmember declared after a method with clauses is still a member.
// Contextual keywords remain usable as variable and function names:
let requires = 1;
let ensures = 2;
let debug = 3;
let invariant = requires + ensures + debug;
Preconditions (requires) ​
A requires clause specifies a precondition checked on every call after
parameter defaults and bindings are evaluated and before the callable does any
other work. Multiple requires clauses evaluate in source order; the first
clause to return false throws ContractError immediately.
Scope in requires ​
- Functions and closures: Parameters and bindings from enclosing lexical
scopes are in scope. Variables declared inside the function body are not yet in
scope. If an arrow function's
requiresclause references an outer variable, the closure captures that variable even if the body itself does not mention it. - Methods: Parameters,
this, and enclosing module bindings are in scope. - Constructors: Parameters and enclosing bindings are in scope.
thisis not in scope because preconditions run before field initializers, initializer lists, andsuper(...)calls allocate the instance:
class BoxedPositive {
value: i32;
new(v: i32) requires (this.value > 0) : value = v {}
Error:
}
In a subclass constructor, the subclass's own requires clauses run first,
followed by its field initializers, and then the superclass constructor's
requires clauses.
asyncfunctions: Preconditions run synchronously when theasyncfunction is called, in the eager prefix before the firstawait. A failedrequiresclause rejects the returnedFuture<T>withContractError, just like an exception thrown before the firstawait.- Generators (
genandasync gen): A generator's body does not start at the call, which only creates the iterator. Preconditions run at the first step, as the first thing the body does, so a call with arguments that break one returns an iterator, and the firstnext(), or thefor-in driving it, throws.
Postconditions (ensures) ​
An ensures clause specifies a postcondition checked in source order on every
normal return from the callable.
Scope and result ​
An ensures condition sees everything requires sees on entry, plus:
thisin constructors: Unlikerequires, a constructor'sensuresclause runs after the constructor body completes, so the newly initialized instance is in scope asthis. When constructing a subclass, the superclass constructor'sensuresclauses run after the superclass body and before the subclass body.result: In any value-returning function, closure, or method,resultis implicitly bound inside eachensuresscope to the value being returned.resulthas the function's declared or inferred return type.
result is subject to three static rules:
resultis scoped strictly toensuresclauses: Body locals may freely be namedresultwithout conflict, andrequiresclauses cannot referenceresult.voidfunctions and constructors have noresult: Referencingresultwhen the return type isvoid(or in a constructor) is a compile-time error:
function reset(): void ensures (result) {}
Error:
- No parameter named
resultwhenensuresis present: Becauseresultinensureswould shadow a parameter of the same name, declaring both is rejected:
function echo(result: i32): i32 ensures (result > 0) {
Error:
return result;
}
Exit paths and exceptions ​
- All normal returns check
ensures: Explicitreturn expr;statements, tail-expression values of blocks and arrow functions, and returns replayed after afinallyblock all pass through the function's postcondition checks. finallyordering: Postconditions run after anyfinallyblocks in the function have finished executing.- Thrown exceptions skip
ensures: When a function exits abnormally viathrow, postconditions are not evaluated. - Uncaught by internal
try: If anensuresclause itself fails, the resultingContractErroris thrown outside the function's owntry/catchhandlers so the function cannot accidentally swallow its own broken postcondition. asyncfunctions: Postconditions run when the body returns, just before the future completes, andresultis the value it completes with. A failure completes the future with theContractError. Such a function cannot return another future for its caller to wait on in place of a value, sinceresultis not known at that return; it canawaitit.- Generators: Postconditions run when the body returns, which is when the
iteration ends: the step that would report the end throws instead. A
generator has no
result. A loop that leaves early disposes the generator without returning from its body, so nothing is checked.
Entry values with old() ​
Inside an ensures condition, old(e) evaluates expression e on function
entry—immediately after all requires clauses succeed—and yields that saved
value when the postcondition runs at return. old(e) has the static type of e.
class Ledger {
var total: i32 = 0;
record(amount: i32): void
requires (amount != 0)
ensures (this.total == old(this.total) + amount)
{
this.total += amount;
}
}
Rules for old() ​
- Direct
ensurescontext only:oldacts as the entry-value operator only when called directly inside anensuresexpression (where it shadows any user-defined function namedold). Inside a nested arrow function written within anensurescondition,oldresolves as a normal identifier because the closure's own parameters do not exist at function entry. - Single argument, no nesting, no
result:old(e)takes exactly one argument, cannot contain anotherold(...), and cannot referenceresult:
class Counter {
var count: i32 = 0;
badNested(): void ensures (old(old(this.count)) == 0) {}
Error:
badResult(): i32 ensures (old(result) == 0) {
Error:
return this.count;
}
}
- Shallow capture:
old(e)saves the value produced byeat entry; it does not deep-clone reference objects. Ifthis.itemsis aGrowableArray<i32>,old(this.items.length)captures thei32length before mutation, whileold(this.items)retains a reference to the same array object.
Loop invariants (invariant) ​
An invariant clause on a while, for, or for-in loop is checked at the
loop header immediately before the loop's continuation check:
- Before the first iteration begins.
- After the body finishes each normal iteration (in a
forloop, after the update clause runs). - After a
continuejumps to the next cycle (again after theforupdate clause).
If the loop exits via break, return, or throw, the invariant is not
re-checked on exit.
function sumFirstN(n: i32): i32
requires (n >= 0)
ensures (result >= 0)
{
var total = 0;
for (var i = 0; i <= n; i += 1)
invariant (0 <= i && i <= n + 1)
invariant (total >= 0)
{
total += i;
}
return total;
}
Scope in invariant ​
An invariant condition sees the bindings visible in the loop's test condition:
while (cond): Enclosing scope bindings; variables bound bywhile (let ...)are not in scope because the invariant runs before pattern matching.for (init; cond; update): Enclosing scope bindings plus any variables declared ininit(such asvar i = 0).for (let item in iterable): Enclosing scope bindings only. The loop elementitemis bound after the header check advances the iterator, soitemis not in scope insideinvariant:
function countTrue(flags: FixedArray<boolean>): i32 {
var count = 0;
for (let flag in flags) invariant (flag) {
Error:
if (flag) { count += 1; }
}
return count;
}
Debug clauses (debug) ​
Prefixing a clause with debug restricts its runtime evaluation to test and
debug builds, for conditions whose cost exceeds the code they guard:
function search(items: FixedArray<i32>, target: i32): i32
requires (items.length > 0)
debug requires (isSorted(items))
debug ensures (result >= -1 && result < items.length)
{
var lo = 0;
var hi = items.length;
while (lo < hi) debug invariant (isSorted(items)) {
// ...
}
return -1;
}
- When
debugclauses run: Underzena test(the test runner) and in builds compiled or run with--debug/-g(zena -g build,zena -g run). - When
debugclauses are omitted: Every build without--debug/-goutsidezena testemits nothing fordebugclauses—no condition evaluation, no$contractFailedcall, and no message string. Reachability analysis skips omitteddebugclauses too, so helper functions called only insidedebugconditions are not linked into ordinary builds. - Unconditional type-checking: The checker validates
debugclauses in every build (boolean type, scoping, no assignments, noawait). - No narrowing:
debug requiresdoes not narrow types in subsequent clauses,ensures, or the body, since the body runs without the check in ordinary builds. - Error message: When a
debugclause fails,ContractError.messageincludes thedebugprefix:debug requires (isSorted(items)) does not hold.
Static clauses (static) ​
Prefixing a clause with static makes it a specification: type-checked in every
build and never checked at run time. It is for a condition that is meant to be
proved rather than run, or that could not run at all.
function search(items: FixedArray<i32>, target: i32): i32
requires (items.length > 0)
static requires (isSorted(items))
static ensures (result < 0 || items[result] == target)
{
// ...
}
- Never emitted: No build evaluates a
staticclause, calls$contractFailedfor it, or carries its message. Reachability skips it, so a function called only in astaticcondition is not linked in. - Type-checked: Its names must resolve and its condition must be a boolean, by the rules every clause follows, so it cannot rot.
- No narrowing: A
static requiresnarrows nothing, for the reason adebugone does not. - One modifier per clause:
debugorstatic, not both. Astaticclause is left out of every build, sodebugwould add nothing. - Why
static:static_assertis the precedent forstaticmeaning "at compile time"; the position keeps it apart from a member'sstatic.
Until a static verifier exists, a static clause is documentation the compiler
keeps honest about names and types. Quantifiers and specification-only
functions arrive with the verifier.
Type checking and narrowing ​
Pure-control condition rules ​
Every requires, ensures, and invariant condition must have type boolean
(no truthy coercion) and cannot contain assignment expressions (=, +=, etc.)
or await expressions:
var counter = 0;
function impure(x: i32): i32 requires ((counter += 1) > 0) {
Error:
return x;
}
Precondition narrowing ​
Because an unmarked requires clause executes on every call in every build and
throws if false, type narrowing from an unmarked requires condition
propagates:
- Into subsequent clauses on the same declaration.
- Throughout the callable's body.
- For immutable parameter bindings, into the callable's
ensuresclauses.
class Cat {
purrs(): boolean { return true; }
meow(): String { return 'meow'; }
}
class Dog {
bark(): String { return 'woof'; }
}
function makeCatSpeak(pet: Cat | Dog): String
requires (pet is Cat)
requires (pet.purrs()) // `pet` is already narrowed to `Cat`
ensures (result == pet.meow()) // parameter narrowing carries into `ensures`
{
return pet.meow();
}
A debug requires clause is omitted in non-debug builds, so it never narrows
types in later clauses, ensures, or the body:
function unsafeLength(text: String | null): i32
debug requires (text != null)
{
return text.length;
Error:
}
Postcondition entry typing ​
Inside a function body, early returns can narrow a nullable parameter along later
paths (for example, if (text == null) return 0; narrows text to String for
the rest of the body). An ensures clause must hold for every return path—including
the early return taken when text was null—so ensures conditions are
type-checked against entry types (plus parameter narrowings from requires), not
path-specific body narrowings:
function stringLength(text: String | null): i32
ensures (result == text.length)
Error:
{
if (text == null) {
return 0;
}
return text.length;
}
Guard the nullable value inside the ensures expression itself when needed:
ensures (text == null || result == text.length).
Runtime evaluation and ContractError ​
Each compiled clause lowers to a boolean branch that calls $contractFailed in
zena:core on the false path, throwing a ContractError:
// Standard prelude class (exported from zena:core):
class ContractError extends Error { ... }
The message of ContractError is the exact source text of the violated clause
followed by " does not hold", for example:
requires (amount > 0) does not holddebug requires (isSorted(items)) does not holdensures (this.count == old(this.count) + by) does not holdinvariant (0 <= lo && lo <= hi) does not hold
Binary size and reachability ​
- A function with contracts pays a conditional branch and a call to
$contractFailedper clause, plus one string constant for the clause text. - A function with
ensuresclauses disables record-result scalar explosion on that function soresultremains addressable as a whole record value. - Programs without reachable contract clauses omit
$contractFailed,ContractError, and all contract message strings during dead-code elimination.
Unsupported positions and restrictions ​
tail return and postconditions ​
A function with one or more ensures clauses cannot use tail return. This is a
permanent semantic rule: WebAssembly's return_call discards the caller's stack
frame before invoking the callee, leaving nowhere to run the caller's
postconditions afterward.
function countdown(n: i32): i32 ensures (result == 0) {
if (n == 0) { return 0; }
tail return countdown(n - 1);
Error:
}
(requires clauses are allowed with tail return, since preconditions finish
before the body runs.)
Constructs not yet supported ​
The checker rejects clauses in the following positions until their lowering or inheritance designs are completed:
| Position | Status |
|---|---|
Methods without a body (interface, abstract, declare) |
requires and ensures rejected |
Functions returning an inline record |
requires supported; ensures rejected |
old(...) of an inline value |
rejected |
| Extension class constructors | requires supported; ensures rejected |
Constructor ensures clauses |
this supported; old(...) inside constructor ensures rejected |
Planned static verification ​
Unmarked clauses run in every build, debug clauses run under zena test and
--debug (-g), and static clauses never run. Future work extends the same
contract surface with compile-time verification (see
Contracts Design):
- Opt-in static verification (
@verify): Dischargingrequires,ensures, andinvariantclauses statically via SMT solvers so proven postconditions and invariants hold for all inputs. - Specification vocabulary in
staticclauses: Universal quantifiers (forall,exists) and specification-only functions, which cannot run and so belong only in clauses that are never emitted.