Termination Checking
Monad checks recursive definitions for termination. The check is structural,
and both compilers run it, on every def, by default.
[!WARNING] The check is structural, and both compilers enforce it. A recursive call is accepted only when one of its arguments is a subterm bound by pattern matching inside the corresponding parameter, so recursion over
List,Nator any inductive type is fine while counting down anI64is not — that needs#[terminating]or#[partial]. It is the case you are most likely to hit.
Why It Exists
In a dependently typed language, the type checker evaluates terms. A non-terminating definition would make the checker loop, and — because a looping definition can be given any type — would make the logic unsound. Only definitions that provably terminate can be unfolded safely.
The Rule: Structural Recursion
A recursive call is accepted when at least one argument is a structural subterm of the corresponding parameter: something bound by pattern matching inside that parameter, and therefore strictly smaller.
This is accepted, because tail comes out of destructuring xs:
def length {A : Type} (xs : List A) : I64 :=
match xs {
empty => 0,
cons a tail => 1 + length tail
}
So is this, recursing on m from succ m:
def double_nat (n : Nat) : Nat :=
match n {
zero => Nat.zero,
succ m => Nat.succ (Nat.succ (double_nat m))
}
Mutual recursion works too, as long as each step is structural:
def is_even (n : Nat) : Bool :=
match n {
zero => true,
succ m => is_odd m
}
def is_odd (n : Nat) : Bool :=
match n {
zero => false,
succ m => is_even m
}
What Gets Rejected
Arithmetic recursion is the common case. n - 1 is a computed value, not a
subterm of n, so the check cannot see that it decreases:
def countdown (n : I64) : I64 :=
if n == 0 then 0 else countdown (n - 1)
Both compilers reject it, in the same words — below is the host's output, with the elaborated call elided:
error: Termination check failed for 'countdown'
Recursive call: (countdown (... n 1i64))
Argument(s) (...) are not structural subterms of parameter(s) (n)
— add #[terminating] if this function is well-founded
The error prints the recursive call after elaboration, so the subtraction
arrives as its desugared Sub dispatch — and the two compilers spell that
dispatch differently (the host inlines the dictionary into a match, this
compiler keeps it as a call), so the middle line is the one line of the message
that differs between them. It is noisy either way, but the parameter names at
the end tell you what it wanted.
The Attributes
Three attributes turn the check off for one definition, in both compilers. They are the only way to write recursion the structural rule cannot see.
#[terminating]
"I have checked this by hand; it is well-founded." Use it when the recursion really does decrease but not structurally — the arithmetic case above.
#[terminating]
def factorial (n : I64) : I64 :=
if n == 0
then 1
else n * factorial (n - 1)
#[partial]
"This may not terminate, and that is intended." Use it for interpreters, driver loops, and search that may not converge.
#[partial]
def spin (n : I64) : I64 := spin n
#[decreasing <measure>]
#[terminating], plus a note to the reader of which argument is meant to be
shrinking. Use it where "it terminates" alone would leave the next reader
guessing.
#[decreasing n]
def fact_acc (n : I64) (acc : I64) : I64 :=
if n == 0 then acc else fact_acc (n - 1) (n * acc)
The named measure is not checked — not against the parameter list, not
against the recursive calls. It is documentation the compiler agrees to accept,
and it exempts the definition exactly as #[terminating] does.
The difference between the three is what you are telling the reader:
#[terminating] is a claim about the code, #[decreasing x] is the same claim
with its reason named, and #[partial] is an admission. Nothing verifies any of
them, so an incorrect #[terminating] passes both compilers — and a definition
the host's checker unfolds while type-checking will hang it.
Practical Guidance
The rule is narrow, so most recursion falls into one of three cases:
- Recursing over
List,Nat, or any inductive type usually just works — recurse on what thematchbound, not on something you computed. - Counting down an
I64needs#[terminating]. This is by far the most common case. - Converting the loop counter to
Natmakes the recursion structural and removes the need for the attribute, at the cost of unary arithmetic. - The standard library uses these attributes freely; they are not a code smell.
Code written this way is accepted by both compilers, and stays accepted if the checker ever grows stricter than the structural rule.
Limitations
In both implementations the check is deliberately simple:
- No termination inference beyond the structural rule — no size measures, no lexicographic orderings, no user-supplied well-founded relations.
- The attributes are trusted, not verified.
- Accumulator-passing recursion where the decreasing argument is not the structurally-matched one is not recognised.
Next, we'll look at linear types.