Contracts
Contracts write a function's assumptions and guarantees into its signature and a loop's inductive assumptions into its header:
function withdraw(account: Account, amount: i32): Account
requires (amount > 0)
requires (account.balance >= amount)
ensures (result.balance == account.balance - amount)
{
// ...
}
The design serves three roles without changing syntax along the way:
always-on runtime assertions (requires, ensures, invariant), test- and
debug-only checks (debug) for expensive predicates, and static verification
with SMT solvers later.
Runtime checks first, static proofs later ​
An unmarked contract clause runs on every call in every build, throwing a
ContractError when violated. That matches how Zena treats array bounds checks
and as downcasts: a safety condition written in source code is enforced at
runtime unless proven redundant.
Starting with runtime execution makes contracts useful immediately—especially in
AI-assisted development, where an agent iterating against zena test gets an
exact ContractError: ensures (result.balance == ...) does not hold message
instead of a silent wrong answer downstream. At the same time, keeping conditions
free of side effects (forbidding assignments and await) keeps the surface ready
for a static verifier to translate the exact same clauses into proof obligations,
following the model of Ada and SPARK.
Where the compiler cannot yet lower a clause—such as on a generator or an
async function's postcondition—the type checker rejects it rather than parsing
and ignoring it. A contract written in a program is never silently skipped.
Parenthesized conditions and contextual keywords ​
Why conditions are parenthesized ​
Languages in the Dafny family write clauses without parentheses:
requires amount > 0, terminated by the next clause keyword or {. In Zena,
unparenthesized conditions fail in two places:
-
Arrow function lookahead: Zena's parser identifies
( ... ) => ...by scanning past the parameter list for=>. In an unparenthesized arrow clause:zenalet pick = (a: boolean, b: boolean, c: boolean) requires a && (b || c) => a;the parser sees
(b || c) => a, mistakesb || cfor an arrow parameter list, and fails. Requiring parentheses around the condition gives the lookahead a balanced(...)delimiter to step over cleanly, just asif,while, andmatchalready use, and matches D (in (c)) and C++26 (pre (c)). -
Future brace expressions: An unparenthesized clause before a function or loop
{body would prevent Zena from ever addingTypeName {field: value}expressions inside conditions without introducing Rust-style expression restrictions before{.
Parentheses also leave clean grammatical room inside the clause for future additions, such as custom failure messages.
Why clauses sit in the signature ​
Putting requires at the top of the { ... } body as a statement would avoid
touching function signatures, at the cost of hiding contracts inside the
implementation. Arrow functions with expression bodies have no { ... } block,
and interface or abstract methods have no body at all. Placing contracts between
the signature and the body keeps them part of what callers and tools read.
Why keywords stay contextual ​
Reserving requires, ensures, invariant, and debug globally would break
ordinary variable names—including invariant inside the Zena compiler's own loop
optimizer and debug options across CLI and build code.
In signature position, nothing valid starts with an identifier after parameters
or a return type, so requires, ensures, and debug before a clause keyword
are unambiguous there. After a loop header, however, while (c) invariant(x); is
already a valid single-statement loop calling a function named invariant.
Requiring a { ... } block body on loops with clauses resolves the overlap:
invariant (...) or debug invariant (...) followed by { or another loop
clause is always a contract clause, while invariant(...); followed by ; is
always a call statement.
Implicit result ​
D and C++26 bind the return value to a user-chosen identifier (out (r; r > 0)).
Using the implicit name result inside ensures scopes (as Eiffel, JML, and
SPARK do) avoids punctuation inside every postcondition and conflicts only when a
function with ensures also names a parameter result, which the checker
diagnoses directly.
Narrowing from preconditions ​
Because an unmarked requires clause runs on every entry and throws before the
body executes when false, anything its condition proves true holds on entry
to the body:
let trimmedLength = (text: String | null) requires (text != null) => text.trim().length;
The checker feeds each requires condition through the same flow-narrowing
engine if uses, carrying narrowed bindings and immutable member paths into
subsequent clauses and the body.
Postconditions follow a stricter rule. Inside a body, an early guard like
if (text == null) return 0; narrows text to String for the lines after the
if. Yet the ensures clause also runs on that early return where text was
null. The checker therefore checks ensures conditions against entry types
plus parameter narrowings from requires, ignoring path-local body narrowings.
Gathering returns and entry values ​
Redirecting returns in ZIR ​
A function can return from many places during lowering: explicit return
statements, the trailing expression of a block or arrow body, and returns held
back while unwinding through finally blocks. Rather than duplicating
postcondition checks at each exit site, ZIR's IrBuilder redirects all return
emissions (ret, retVoid, retMulti) while lowering a function with ensures
clauses into branches targeting a single postcondition block whose parameters
carry the returned value.
After lowering the body (and after any finally blocks along the return path),
that postcondition block binds result, evaluates every ensures clause outside
the function's own try handlers, and executes the real return. Because a tail
call (tail return) discards the frame before the callee runs, tail return is
disallowed on functions with ensures clauses.
Capturing old(e) ​
old(e) evaluates e in the entry block immediately after preconditions succeed
and stores the SSA value so the postcondition block can read it on exit. Because
the entry block dominates every return path, no extra state flag is needed for
ordinary functions. Reference values are kept by reference rather than cloned;
postconditions inspecting prior collection contents either query scalar snapshots
like old(this.items.length) or explicitly copy what they need.
Zero cost when unused ​
Each clause lowers to a branch calling $contractFailed(message) in zena:core.
Reachability analysis roots $contractFailed and the clause message string only
when walking a reachable contract clause (noteContractClause), so modules
without contracts emit zero extra code or data.
Debug and ghost clauses ​
Not every useful contract belongs in a production binary:
-
Expensive runtime checks (
debug, implemented): A precondition likeisSorted(items)takesO(n)work to guard anO(log n)binary search. A global compiler flag that strips all contracts in release builds is too coarse—cheap guards likeamount > 0should stay on, and only the author of a clause knows its algorithmic cost. Zena places the contextualdebugmodifier directly onrequires,ensures, orinvariant:zenafunction search(items: FixedArray<i32>, target: i32): i32 requires (items.length > 0) debug requires (isSorted(items)) { // ... }debugclauses compile and run underzena testand in builds made with--debug/-g(zena -g build,zena -g run), matching the CLI flag name (chosen overtestandaudit). In every other build, both reachability and lowering skip the clause (WasmModule.runsContractClause), emitting no condition, call, message string, or otherwise-unused helper functions. The type checker still checksdebugclauses in every build so they cannot rot, anddebug requiresnarrows nothing because the body runs without the check in ordinary builds. -
Static verification and
ghostclauses (planned): When Zena adds opt-in SMT verification (@verify), ordinaryrequires,ensures, andinvariantclauses need no syntax change—proven clauses can elide their runtime checks (while retaining boundary checks for unverified callers). Specifications that cannot execute at runtime at all, such as quantifiers over unbounded ranges or inductive helper lemmas, will use aghostmodifier in the same position:zenawhile (lo < hi) invariant (0 <= lo && lo <= hi) ghost invariant (forall j :: 0 <= j < lo ==> items[j] < target) { // ... }
Open questions and current gaps ​
- Contract inheritance on interfaces and overrides: Methods without bodies
(
interface,abstract,declare) currently reject clauses. Behavioral subtyping requires overrides to accept at least what the base contract accepts (weakening or keeping preconditions) while guaranteeing at least what the base promises (strengthening or keeping postconditions), and the compiler must decide whether checks live at dispatch trampolines or inside implementations. - Generators: A generator body does not run until
.next()is called and yields zero or more values rather than returning once. Whetherrequiresruns at the call site (in the generator ramp) or on first step, and howensuresdescribes yielded elements vs. completion, remain open. ensuresonasyncfunctions: Async functions split into state machines after ZIR lowering, so propagatingold(e)values across suspension points and runningensureswhen the returnedFuture<T>resolves are still being wired up (requiresalready runs in the eager prefix).- Constructor
old()across inheritance: Class constructors compile into an initializer phase and a body phase so subclasses can initialize fields before allocation; threadingold(e)values from entry in<ctor-init>to postconditions in<ctor-body>is tracked separately. - Side-effecting helper calls: Assignments and
awaitinside conditions are rejected today, while calls to impure functions are not yet restricted until purity checking is formalized.
→ Working documents:
contracts.md,
formal-verification.md,
ai-first-language.md