Refinement Types
Normally, a type like Int only tells the compiler “this is a whole number” — it says
nothing about which whole numbers are actually valid here. A refinement type lets
you attach an extra condition to a type, so {Int | _ >= 0} means “an Int, and
specifically one that’s never negative.” The condition (_ >= 0 here) is called a
predicate — just a true/false check on the value.
The clever part is that March doesn’t just remember the condition as documentation — it actually proves it, at compile time, using an automated reasoning tool called an SMT solver (specifically Z3). An SMT solver is software that can mechanically work out whether a set of logical/arithmetic statements is possible or is a contradiction — the same kind of tool used to verify hardware designs and cryptographic protocols. In March, it’s put to work checking your function contracts, so a whole class of bugs — out-of-bounds indices, negative sizes, division by zero, broken invariants — becomes a compile error instead of a runtime panic.
If you’ve heard the term dependent typing — types that depend on values, not just other types — refinements are March’s practical, restricted slice of it. You get the safety where it pays off (preconditions, bounds, postconditions) without a proof assistant’s ceremony — and, crucially, without false positives: if the checker can’t prove a value is definitely wrong, it stays silent rather than guessing.
Requires Z3. Refinement checking runs only when the
z3solver is on yourPATH. Without it, the predicates type-check as their base type and no refinement diagnostics are produced (the build still succeeds).
New to this? Read Syntax through
Contracts Compose
and you’ll be able to write and read everyday refinements. Everything after
Postconditions goes deeper — measures over your own data
structures, Bool/Float/tag refinements, the strict cap verified /
cap no_panic modes, and the full Limitations list. If you
just want to know why a specific contract isn’t catching something, jump
straight to Limitations or to
--refine-report.
The Problem They Solve
fn at(xs : List(Int), i : Int) : Int do
-- if i is out of range this panics at runtime
List.nth(xs, i)
end
at([10, 20, 30], 5) -- boom, eventually, at runtime
A refinement moves the contract into the type, where the compiler checks it:
fn at(xs : List(Int), i : {Int | _ >= 0 && _ < len(xs)}) : Int do
List.nth(xs, i)
end
at([10, 20, 30], 5) -- compile error: 5 is not < len([10,20,30]) = 3
at([10, 20, 30], 1) -- ok
Syntax
A refinement is written { BaseType | predicate }. The placeholder _ stands
for the value being refined:
{Int | _ >= 0} -- a non-negative Int
{Int | _ > 0} -- a positive Int
{Int | _ != 0} -- a non-zero Int (safe divisor)
{Int | _ >= 0 && _ < 100} -- a bounded Int
You can name the value instead of using _:
{v : Int | v >= 0 && v < 100}
Refinements appear anywhere a type does — parameters (preconditions) and return types (postconditions):
-- precondition: callers must pass a positive size
fn chunks(xs : List(a), size : {Int | _ > 0}) : List(List(a)) do ... end
-- postcondition: this function promises a non-negative result
fn count(xs : List(a)) : {Int | _ >= 0} do List.length(xs) end
The supported predicate fragment is Int/Bool linear arithmetic:
+ - * (multiplication by a literal), the comparisons == != < <= > >=, the
connectives && || not, integer/bool literals, and measures (below).
Bool and Float values are refinable too — see
Bool and Float Refinements.
How Checking Works — Definite Failure
Think of the checker as a cautious lawyer rather than an eager one: it only objects when it can prove, beyond doubt, that you’re wrong. There are exactly three outcomes for a predicate at a given point in your code:
- The argument always satisfies the predicate → pass, silently.
- The argument never satisfies it → compile error with a counterexample.
- It might or might not satisfy it (the value is unknown, or the solver can’t decide) → skipped, silently.
fn take_pos(n : {Int | _ >= 0}) : Int do n end
take_pos(-3) -- error: -3 can never be >= 0
take_pos(5) -- pass
fn f(k : Int) : Int do
take_pos(k) -- skipped: k is unknown, might be >= 0
end
The consequence: no false positives, by design — but incomplete. The checker never flags correct code, and never blocks a build over something it can’t disprove. It also won’t prove everything you might hope; facts it can’t establish are conservatively let through. This trade is deliberate — a refinement checker that cries wolf is one developers turn off.
Path Sensitivity — Guards Establish Facts
A guard you write becomes a fact the solver can use for the rest of that branch. The then-branch learns the condition is true; the else-branch learns it’s false:
fn get(xs : List(Int), i : Int) : Int do
if i >= 0 && i < List.length(xs) do
at(xs, i) -- ok: the guard proves the precondition here
else
0
end
end
(len, used in earlier examples, is a special name usable only inside a {...}
refinement predicate — you can’t call it as an ordinary function. In plain code, like
the guard above, use List.length instead; the solver knows they mean the same thing.)
Length guards count. A guard you’d write anyway —
if List.length(ys) > 0, orif String.byte_size(s) > 0— establishes exactly the fact alen-bearing contract like{List(Int) | len(_) > 0}asks for: the checker treats the qualifiedList.length,String.byte_size, and thestring_byte_lengthbuiltin as spellings of thelenmeasure (a barelength,String.codepoint_count, orstring_lengthdoes not count). The same holds for a qualified spelling written inside the braces. The connection is narrow and occasionally withdrawn for a whole compilation unit — see Limitations for the summary and the appendix for the exact rules.
match arm guards (when) work the same way. An assert(p) acts as an
assume — it injects p as a fact for the code that follows:
fn g(i : Int) : Int do
assert(i >= 0)
take_pos(i) -- ok: the assert established i >= 0
end
Use assert as the escape hatch for facts the checker can’t derive on its own
(it’s the place a hand-proved lemma lives).
Contracts Compose — A Parameter’s Promise Holds Inside Its Body
There are two different ways a fact gets established, and it’s worth keeping them apart in your head:
- a guard — a runtime test you wrote, like
if List.length(ys) > 0 do …, which is what the section above is about; and - a declared contract — a refinement on a parameter’s type, which the caller already had to satisfy before the function could be entered.
The second one is a promise you were handed, so you shouldn’t have to check it again. And you don’t: a refined parameter’s own predicate is a fact inside the function’s body. Which means contracts compose — a function that requires something can pass its own parameter straight on to another function requiring the same thing, no guard anywhere:
fn inner(xs : {List(Int) | len(_) > 0}) : Int do 0 end
fn outer(ys : {List(Int) | len(_) > 0}) : Int do inner(ys) end
fn main() : Int do outer([1]) end
Both calls are proved — --refine-report says 2 proved, 0 violated, 0 trusted, 0 skipped.
A contract can be threaded through as many hops as you like without re-guarding a list
you’d already promised was non-empty. That’s what makes the standard library’s contracts
worth having:
mod Y do
cap verified
fn head_of(xs : {List(Int) | len(_) > 0}) : Int do
List.head(xs) -- proved from head_of's own contract
end
fn main() : Int do head_of([1, 2]) end
end
List.head wants a non-empty list; head_of already promised one; done. That
compiles clean even under cap verified, which is the strict setting that turns
every undischarged obligation into an error.
This works for every refinement shape: Int, Float, Bool, String
lengths, record fields, list len, your own @[measure] over a tree, and a
constructor tag ({Option(Int) | is_Some(_)}) all compose. The tag shape
composes only for the constructor you actually promised — promising
is_None(_) does not discharge a callee wanting is_Some(_). See
Limitations.
Two things it deliberately won’t do for you. First, a weaker promise can’t
launder a stronger requirement. Declare outer(ys : {List(Int) | len(_) >= 0}) —
which is true of every list and therefore says nothing — and the inner call goes back
to being skipped: not proved, and not reported either, because nothing here is
definitely wrong. Second, rebinding the name drops the fact, since the promise
belongs to the value, not the spelling:
fn outer(ys : {List(Int) | len(_) > 0}) : Int do
let ys = List.tail(ys) -- this call is fine: proved from ys's contract
inner(ys) -- skipped: the new ys promises nothing
end
A match arm that binds the same name (Cons(_, ys) -> inner(ys)) behaves the same.
Both leave the call quiet rather than flagged — the checker never guesses.
One related gap, which predates all of this and is easy to trip over: a fact does
not travel through a local let, for any type at all. let u = 5 followed by
take_pos(u) against {Int | _ > 0} is skipped, even though 5 obviously satisfies
it. Pass the value directly, or state the fact with assert.
An annotation on a let is checked, not assumed
You can write a refinement on a local binding, and it carries a fact forward — but it has to earn it first:
fn outer() : Int do
let ys : {List(Int) | len(_) > 0} = [1]
inner(ys) -- proved: the annotation held
end
Two things are checked there, not one: the annotation against [1], and then
inner’s precondition against the fact the annotation established. Write something
false and you get told:
let ys : {List(Int) | len(_) > 0} = []
-- refinement violation: bound expression does not satisfy type annotation `len(_) > 0`
The annotation is checked, not believed on sight: it earns its fact only when the bound expression actually satisfies it.
The usual stance still applies at the other end: an annotation the checker can neither prove nor refute is skipped, never reported. But it then grants no fact either, so it can’t quietly prop up a later call:
fn go(zs : List(Int)) : Int do
let ys : {List(Int) | len(_) > 0} = zs -- skipped: nothing known about zs
inner(ys) -- also skipped, not proved
end
All three spellings of the value work the same way — _, a declared binder
({v : List(Int) | len(v) > 0}), or the bound name itself (len(ys) > 0).
Postconditions
A refined return type is checked on every return path of the function, under the path/scope reaching it:
fn clamp_low(n : Int) : {Int | _ >= 0} do
if n < 0 do 0 else n end -- both branches satisfy _ >= 0 ✓
end
fn bad(n : Int) : {Int | _ >= 0} do
if n < 0 do n else 0 end -- error: the n < 0 branch returns a negative
end
A postcondition is checked the same way a precondition is — a compile
error when it can never hold, silent proof when it always does, and a
silent skip when the checker can’t decide either way. It also counts
toward --refine-report (tagged
postcondition rather than precondition, in the report’s by kind line)
and, inside a cap verified module, an undischarged postcondition is a
compile error exactly like an undischarged precondition:
mod Checked do
cap verified
fn mk(z : Int) : {Int | _ > 0} do
z -- error under cap verified: unproven
end
end
@[trusted] rescues a postcondition the same way it rescues a precondition —
see cap verified below.
What doesn’t carry through: proving a plain postcondition (as above) is
different from proving a relational one that relates a measure across an
operation — size(insert(t, x)) == size(t) + 1 — which needs the checker to
supply an induction hypothesis at each structurally-recursive call. That
narrower, more powerful case is covered in Limitations, under
“Relational postconditions work, within structural recursion.”
Measures — Refining over Data Structures
Every predicate so far has been about a single plain number, like i >= 0. But often
the bound you actually care about depends on the shape of a data structure — “the
index must be less than the list’s length,” say. A measure is how you bring that
into a predicate: it’s an ordinary-looking function from a value to an Int (or
Bool), except the compiler is allowed to reason about it symbolically, not just run
it. The built-in len measures a list this way:
fn at(xs : List(Int), i : {Int | _ >= 0 && _ < len(xs)}) : Int do ... end
You can define your own with the @[measure] attribute — for example the size
of a tree:
type Tree(a) = Leaf | Node(Tree(a), a, Tree(a))
@[measure]
fn size(t : Tree(a)) : Int do
match t do
Leaf -> 0
Node(l, x, r) -> 1 + size(l) + size(r)
end
end
fn get(t : Tree(a), i : {Int | _ >= 0 && _ < size(t)}) : a do ... end
Once a function is marked @[measure], the solver treats its own definition as a set of
rules it’s allowed to use — so for a value whose shape it can see (a literal list, an
explicit Node/Leaf tree), it can effectively “run” the measure symbolically:
from the equations above it works out size(Node(Leaf, x, Leaf)) = 1, so an
out-of-bounds index into a literal tree is caught, and size(t) >= 0 is known for any
t. Measures may call other measures and be mutually recursive (e.g. a
Tree/Forest pair), and the built-in List is modelled too, so a user
length measure over List(a) reasons the same way as size.
Requiring a non-empty collection
In the examples above the measure describes a different parameter — len(xs)
bounds the index i. A measure can just as well describe the refined value
itself, which is how you say “this list must not be empty”:
fn head(xs : {List(a) | len(_) > 0}) : a do ... end
Inside the predicate you can name the refined value three ways, and they all mean
the same thing and are checked identically — the anonymous _, your own binder,
or the parameter’s name:
fn head(xs : {List(a) | len(_) > 0}) : a do ... end
fn head(xs : {v : List(a) | len(v) > 0}) : a do ... end
fn head(xs : {List(a) | len(xs) > 0}) : a do ... end
Pass a list the compiler can see is empty and you get an error; pass one it can see is non-empty and it says nothing; pass one it can’t see into and it stays quiet rather than guessing:
head([]) -- error: `len(_) > 0` can never hold here
head([1, 2]) -- fine
fn f(ys : List(Int)) : Int do head(ys) end -- skipped: length unknown
Thirteen standard-library functions that panic on an empty argument now carry
this contract — List.head, tail, last, minimum_int, maximum_int, the
prelude’s head/tail, Stats.mean/min_val/max_val, Gen.element/one_of,
and Random.choice — so List.head([]) is a compile error rather than a crash.
Each contract is taken from that function’s own panic message, so it never
demands more than the code already checked, and every panic stays in place to
catch the cases the compiler skips.
List.nth is the fourteenth, and the only one whose contract talks about a
different parameter rather than the refined value itself:
fn nth(xs : List(a), n : {Int | _ >= 0 && _ < len(xs)}) : a do ... end
So List.nth([1, 2, 3], 7) and List.nth([1, 2, 3], -1) are compile errors
now. An index the compiler can’t pin down — by far the common case — stays
silent, just as head(ys) does for a list it can’t see into. Before this
shipped the whole standard library and four real projects (forgepm,
bastion, conduit, depot) were swept for it: zero calls became errors.
Under cap verified, though, that same unbindable index is a hard error
instead of a silent skip — that mode’s whole premise is that every obligation
gets discharged, so “can’t tell” is no longer good enough.
An ordinary List.length(xs) > 0 guard does satisfy the requirement, so these
contracts bite on a list you checked at runtime and not just on literals — see
the solver really does connect List.length to
len for exactly when that
connection applies, and the (narrow) circumstances in which it’s dropped.
And you don’t need a guard at all when the enclosing function already declares the same contract — that’s a separate mechanism, and it’s what Contracts Compose is about.
Your own measures work in this position too, not just the built-in len:
fn inner(t : {Tree(Int) | size(_) > 0}) : Int do 0 end
inner(Leaf) -- error: size(Leaf) is 0
inner(Node(Leaf, 5, Leaf)) -- fine: size is 1
Both directions are checked: the checker reasons about the actual argument, so
inner(Leaf) is rejected and inner(Node(Leaf, 5, Leaf)) passes.
The measure soundness gate
The solver trusts a @[measure] completely — it treats the function’s body as a fact
about the world. That means a badly-behaved measure (one that never finishes, divides
by zero, or skips a case) could let the solver “prove” something false. To prevent that,
the compiler requires every measure to be provably well-behaved before it’s allowed to
be used at all — a hard compile error if it isn’t. A @[measure] is rejected if it:
- has an effect (
spawn,send,dbg,assert) — it must be pure, - can diverge or abort (
panic,todo,exit) — it must always finish, - is non-total — a non-exhaustive
matchon its parameter, or a//%that could divide by zero — it must handle every case, - is not structurally recursive — a recursive call whose argument isn’t a component of the matched parameter — each call must work on a strictly smaller piece of the input, so it’s guaranteed to terminate.
A measure that is sound but outside what the encoding can model (see limitations, below) isn’t an error — it simply falls back to weaker, symbolic reasoning. “Weaker” can mean nothing at all: a measure whose value is a scalar constructor field discharges neither a predicate nor its negation. See Limitations — that case is not always warned about.
Bool and Float Refinements
A Bool parameter or return carries a predicate over the ordinary boolean
operators — ==, !=, &&, ||, not — against true and false:
fn commit(confirmed : {Bool | _ == true}) : Int do ... end
commit(false) -- rejected: `_ == true` can never hold at `false`
commit(true) -- fine
Write the operator form: {Bool | not _} does not parse, so use
{Bool | _ == false}. An unknown Bool is skipped, and a guard works as you’d
expect — if k do commit(k) else 0 end passes.
A Float parameter or return carries a comparison — >=, >, <=, <,
==, != — against float literals or another float value:
fn sqrtish(x : {Float | _ >= 0.0}) : Float do ... end
fn ratio(n : Float, d : {Float | _ != 0.0}) : Float do n /. d end
sqrtish(0.0 -. 1.0) -- rejected
sqrtish(4.0) -- fine
ratio(1.0, 0.0) -- rejected: a zero divisor
Why floats are not modelled as reals
Float predicates go to Z3’s bit-precise IEEE-754 FloatingPoint theory:
Float64, fp.geq / fp.gt / fp.leq / fp.lt, and fp.eq for equality.
Modelling floats as mathematical reals would break the no-false-positives
promise. Consider not (x >= 0.0) && not (x <= 0.0). Over reals, trichotomy
makes that impossible — and since the checker objects exactly when a
predicate can never hold, it would flag this contract on every argument. Over
floats it is perfectly possible, witnessed by NaN, which compares false
against everything; so the checker correctly stays silent.
Equality is fp.eq (IEEE equality), not bitwise identity. Under bitwise
identity -0.0 would differ from 0.0 and {Float | _ != 0.0} would accept a
negative zero — just as bad a divisor as a positive one. Under fp.eq,
-0.0 == 0.0 holds (so a negative zero is rejected) and NaN equals
nothing, not even itself.
Float arithmetic inside a predicate — {Float | _ +. 1.0 > 0.0} — is out of
scope: modelling it needs rounding-mode reasoning, so the whole predicate is
skipped rather than approximated. Arithmetic over float literals is different:
0.0 -. 1.0 is folded to a constant first and is fully checked, which is how a
negative literal (which March has no direct spelling for) still works.
Constructor Tags — Refining over ADT Variants
Refinements aren’t limited to numbers and sizes. You can also require that a
value is a particular variant of a union type. Every constructor — in your own
types, and in the built-in Option, Result and List — implicitly comes with
an is_<Ctor> tester you can use inside a predicate. You don’t declare
these; writing type Shape = Circle(Int) | Square(Int) gives you is_Circle
and is_Square for free.
That lets a function say “I need the populated case” in its own signature:
fn unwrap(o : {Option(Int) | is_Some(_)}) : Int do ... end
unwrap(Some(1)) -- fine
unwrap(None) -- error: `None` can never satisfy `is_Some(_)`
This is what backs the standard library’s Option.unwrap/expect and
Result.unwrap/unwrap_err/expect, so Option.unwrap(None) and
Result.unwrap(Err("boom")) are now compile errors rather than runtime panics.
The tester name is exact-case. is_Some is the tester for the constructor
Some; is_some is not a tester — it’s the lowercase stdlib helper
Option.is_some. Get the case wrong and you don’t silently get a different
meaning, you get a warning that the refinement isn’t being checked:
is_someis not a measure or known predicate, so this refinement is not checked. Annotate the function@[measure], or use a supported predicate.
Facts from a match
A constructor literal at the call site is the easy case. The more useful one is
a match: entering an arm tells the checker what the scrutinee’s tag is for
everything inside that arm.
fn f(x : Option(Int)) : Int do
match x do
None -> unwrap(x) -- error: inside this arm, `x` is definitely `None`
Some(v) -> unwrap(x) -- fine: inside this arm, `x` is definitely `Some`
end
end
This narrowing is deliberately conservative. Where it stops, the checker goes quiet rather than guessing — so these are all silence, never false alarms:
- The scrutinee has to be a plain variable.
match mk() do …matches an expression, and there’s no stable name to attach a fact to, so nothing inside the arms is narrowed. Bind it with aletfirst if you want the fact. - A pattern that rebinds the name ends it. Matching
ywithSome(x) ->tells you nothing aboutx— thatxis a fresh name for the payload, not for the scrutinee. - An
aspattern isn’t narrowed.None as z ->binds the whole scrutinee under a second name, but the arm’s head is anaspattern rather than a bare constructor pattern, so no tag fact is recorded — not forz, and not for the scrutinee. WriteNone ->if you want the narrowing. - An ambiguous constructor name is skipped. If two types in scope both
declare a constructor
Row, thenis_Rowdoesn’t identify a particular type and isn’t checked. -
Rebinding the name discards the fact. A narrowing is recorded against a name, so anything that rebinds that name inside the arm — a
let, alet?, a lambda parameter, an innermatchbinder — drops it:match x do None -> let x = Some(1) unwrap(x) -- fine: this `x` is a different value Some(v) -> v endThis is the same rule that governs facts established by an
ifguard, and it’s what keeps a fact about an outer value from being wrongly attributed to an inner one.
As everywhere else, the definite-failure stance applies: an Option whose tag
the checker can’t determine is not an error.
Later arms also learn what the earlier ones ruled out. Reaching an arm means every arm above it failed to match, so for each of those whose failure is decided purely by the tag, the scrutinee is known not to carry it:
fn mean_safe(xs : List(Float)) : Result(Float, String) do
match xs do
Nil -> Err("empty")
_ -> Ok(mean(xs)) -- `mean` needs len > 0; the `_` arm has it
end
end
For a list, a tag test is a statement about length — is_Nil(xs) means
len(xs) = 0 and is_Cons(xs) means len(xs) > 0 — so the exclusion above
discharges a len-bearing precondition directly. This is what makes the
safe-wrapper idiom (match the empty case, return Err/None, do the real work
in the other arm) check out. The same idea generalizes to a user @[measure]:
a base-case arm whose body is a literal gets an axiom linking its constructor’s
tester directly to the measure’s value, so the exclusion connects there too.
An earlier arm licenses nothing if it carries a guard or a refutable
sub-pattern, because either can fail with the tag still matching:
Cons(0, _) does not match Cons(1, []), which is nonetheless a Cons, and
Nil when flag fails whenever flag is false. So
match xs do
Nil -> Err(…)
Cons(_, Nil) -> Err("need at least 2")
_ -> Ok(std_dev(xs)) -- knows only len > 0, NOT len > 1
end
still abstains on a len > 1 requirement, which is the honest answer.
Seeing What Got Checked — --refine-report
Because March stays quiet about anything it can’t decide, silence has two very
different meanings: “I proved this” and “I couldn’t tell, so I said nothing.” From the
outside they look identical — which is exactly how a {List(a) | len(_) > 0} contract
once shipped enforcing nothing while every test stayed green.
--refine-report turns the checked fraction into a number you can look at:
$ march --check --refine-report stdlib/list.march
refinement obligations (user code): 0 proved, 0 violated, 0 trusted, 5 skipped
skipped (solver-undecided): 5
by kind: 5 precondition, 0 postcondition
refinement obligations (user + stdlib): 8 proved, 0 violated, 0 trusted, 28 skipped
skipped (unreflectable-predicate): 1
skipped (solver-undecided): 27
by kind: 36 precondition, 0 postcondition
One wrinkle to know before you run it: clear .march/cas/artifacts-v2 first. A
--check whose sources are already in the build cache exits straight away, before
anything is parsed — so the report never runs and you get no output at all, while
still exiting 0. That looks exactly like “nothing to report”, which is the very
confusion this flag exists to clear up. (.march/cas/vc is a different cache, holding
solver verdicts; clearing that one makes z3 re-decide, but doesn’t change whether the
report prints.)
You get two counts because the compiler quietly prepends the whole standard library to every compilation. User code counts only the call sites in the file you named — that’s the one to watch while writing a module. User + stdlib counts everything raised in the run, which makes a good whole-program coverage number.
March’s own CI ratchets on both directions: a ceiling on skips (more skips means
less is being checked) and a floor on proofs. The floor matters more than it
sounds — a ceiling on its own is satisfied perfectly by a checker that raises no
obligations at all, and the floor is read from a small fixture whose one obligation is
proved by a List.length guard, so it collapses to zero the instant the measure
alias stops working. This is the failure mode the report exists to expose: a skip and a
proof both exit 0, and only the count can tell them apart.
Every skip says why: the predicate uses vocabulary the checker can’t translate
(unreflectable-predicate), the argument’s own value didn’t translate
(unreflectable-subject), a symbol would have needed two different sorts
(sort-conflict), the float wellsortedness gate rejected it (float-sort-gate), a
measure alias the guard relied on had been withdrawn (alias-withdrawn — see below),
or the solver simply didn’t decide (solver-undecided).
The counts include both preconditions checked at call sites and
postconditions — a function’s own return value checked against its declared
return type. Each obligation is tagged with its kind, shown as a by kind
breakdown line under each slice; a proved postcondition counts toward the same
“proved” headline as a proved precondition, and cap verified
(below) escalates an undischarged one of either kind.
cap verified — Making Silence an Error
Everything above is built around never crying wolf: if March can’t prove something is definitely wrong, it keeps quiet. That’s the right default, but it means a contract can be technically legal and practically inert.
If you want the opposite deal for a particular module — “I want these contracts to be
a guarantee, and I want to be told when they aren’t” — declare cap verified. Inside
that module, a precondition at a call site that the checker can’t discharge becomes a
compile error:
mod Checked do
cap verified
fn head_of(xs : {List(Int) | len(_) > 0}) : Int do
match xs do
Cons(h, _) -> h
Nil -> panic("empty")
end
end
fn ok(ys : List(Int)) : Int do
if List.length(ys) > 0 do head_of(ys) else 0 end -- proved, so no error
end
end
Take the guard away and the same call fails the build, telling you which precondition, on which function, and why it couldn’t be discharged:
`cap verified` module: cannot verify precondition `len(_) > 0` on `head_of`
(solver-undecided: the solver proved neither the predicate nor its negation)
note: guard the call or strengthen what is known here, rewrite the predicate
into the fragment the checker supports, or remove `cap verified` from this
module — it asks for every obligation to be discharged
When the guard is right and the error still fires
The length aliases (List.length, String.byte_size, string_byte_length) are
withdrawn for the whole compilation unit as soon as anything in it binds that
name — the check is syntactic and doesn’t ask whether the competing binding could
actually win where you called it. Normally that costs you nothing but a proof. Inside
cap verified it costs you a build, so the message says so rather than blaming the
solver:
`cap verified` module: cannot verify precondition `len(_) > 0` on `head`
(alias-withdrawn: the guard uses `List.length`, but this compilation unit also
BINDS that name, so the checker withdrew its built-in measure meaning and the
guard proved nothing)
note: at least one binding of `List.length` in this compilation unit
(ver3.march:5) withdrew the alias for the WHOLE unit, including this call — …
The culprit can be somewhere you’d never suspect: a nested mod Internal do mod List
do fn length … that nothing can even call as List.length, an unrelated function’s
let string_byte_length = n + 1, or a definition inside a MARCH_LIB_PATH dependency
you never opened. The span in the note is where to look — it names at least one such
binding, and the alias only comes back when every one of them is gone. Rename them, or
state the fact you need as a refinement instead of a runtime guard.
You’ll only see this reason when the withdrawal is plausibly what stopped the proof:
the predicate has to use the affected measure, and this call’s own argument has to be
guarded by the withdrawn spelling, positively, on the matching kind of value. A guard
on a different list, a List.length guard in front of a string contract, a guard
on the else side (which disproves the predicate rather than failing to prove it), and
an unguarded call all keep the plain solver-undecided message — because in each of
those the binding you’d be sent to rename is not the reason anything failed.
The price of that caution is coverage: a guard laundered through a local
(let n = List.length(ys) and then if n > 0), applied to something other than a
plain variable, or established up in a caller falls back to the general message even
when a withdrawal really was the cause. The reason exists to explain one specific
confusion, not to claim every skip.
Know the edges before you reach for it. It’s strictly opt-in and scoped to the
module that writes it: a cap verified module calling an ordinary one doesn’t make
the callee strict, and nested modules don’t inherit it (they can’t — the standard
library arrives as sibling modules, and inheriting would turn all of it strict at
once).
It reaches every declaration form in the module it’s written in — a call inside an
impl method, an interface default body, a top-level let, an actor handler or a
test all raise obligations that get escalated. Both this walk and cap no_panic’s are
exhaustive over the declaration forms, so a future one is a compile error in the compiler
rather than a new silent hole.
It also covers postconditions: an undischarged return refinement is a
compile error under cap verified too, exactly as a precondition is — see
Postconditions above for the example.
One real limitation, and one escape hatch, worth knowing before you rely on it:
-
A refinement in an
interface’s own method signature isn’t enforced — and the compiler tells you so. Writefn run : a -> {Int | _ > 0} -> Intin the interface and no call site is obliged by it. Nothing assumes it either, so it’s a missing check rather than an unsound one. Writing a refinement there produces a warning:the interface signature of
runcarries a refinement, which enforces nothing: an interface method signature is never read by the refinement checker, so no call site is obliged by this predicate and no body may assume it. Write the refinement on the correspondingimplmethod’s own signature instead — a refinement on its return type is always checked, and one on a parameter is enforced when the method name is unambiguous (exactly oneimpldefines it and no top-levelfnshares the name).Inside a
cap verifiedmodule this is an error, not a warning.cap verified’s escalation otherwise fires only on undischarged obligations in the ledger, and an inert interface signature raises none — but the capability’s whole promise is “if it compiles, it is proved,” and this is exactly the shape of silent-no-op contract it exists to catch, the same reasoning that already made thesig/externcase below a warning everywhere. Outsidecap verifiedthe message above is unchanged and still only a warning.The same silent-no-op shape exists for a
sigascription and anexterndeclaration, and both warn too:sig Store do fn put : Int -> {Int | _ > 0} endcompiles clean while enforcing nothing, because asigis an ascription on what a module exports, not a body a call could be checked against — write the refinement on the module’s ownfninstead. Anexternrefinement is the more fundamental case: the callee isn’t March code, so there’s no body to check a return value against, and no amount of rewiring makes it enforceable in principle. The remedy there is a thin March wrapper around the extern call that checks the foreign result itself.Enforcement on the
implmethod’s parameter, when you use that spelling, is conditional. Animplmethod’s parameter refinement obliges callers only when the method’s name unambiguously denotes it: nofnin the same module owns the name, and only oneimpldefines the method. A call is resolved here by name while it dispatches by type, and checking correct code against a predicate it never touches is the one failure this subsystem must never have. When the name is ambiguous the refinement binds nobody — it’s stripped from the body too, so it can’t discharge anything either. Unenforced means unusable in both directions, never “assumed inside the body but demanded of no caller” — which would otherwise letfn run(b, k : {Int | k != 0})makem / kprovable undercap no_panicwhilerun(Box(4), 0)compiled and then divided by zero. -
@[trusted]is a per-function escape hatch. Annotate a single function@[trusted]and any obligation inside it that the checker could not otherwise discharge is accepted as an assertion instead of an error — recorded as its ownTrustedverdict in--refine-report, never folded intoproved. It never suppresses a definite violation (a predicate the solver proved can never hold is a bug in the annotation, not something to wave through), and it is scoped to the one function that carries it — a sibling function in the samecap verifiedmodule is unaffected. Putting@[trusted]on a function outsidecap verifiedwarns, since it would otherwise silently do nothing.
That makes cap verified viable for a whole module even when one call site
genuinely cannot be proved, without switching off verification for everything
else in it.
cap no_panic — Divisions That Can’t Panic
cap verified’s sibling takes the same “silence is not good enough” stance and points
it at one specific runtime panic: integer division by zero. Declare cap no_panic in a
module and every / and % in it must have a divisor the checker can prove
non-zero. Anything short of a proof is a compile error — that’s the whole promise, and
it’s why this capability fails closed where the default refinement stance fails open.
A divisor is discharged by a literal, by a path condition, or by a refinement on the parameter it came from. Both sides of a guard count:
mod NonlinearDivisor do
needs IO.Console
cap no_panic
fn scale(d : {v : Int | v * v > 0}) : Int do
10 / d
end
fn guarded(d : {v : Int | v * v > 0}) : Int do
if d == 0 do 0 else 10 / d end
end
fn main() do
println(int_to_string(scale(2) + guarded(5)))
end
end
Both of those are accepted. v * v > 0 is exactly v != 0 over the integers, and the
checker now hands such a predicate to the solver rather than refusing to read it —
rejecting a complete proof for being written unusually was a false positive on
correct code. The stance itself hasn’t moved: a predicate that reflects but proves
nothing (v * v >= 0, true of every integer) is still an error, and so is one the
solver can’t settle. And on the else side of if d == 0 the fact in scope is
not (d == 0), which discharges the division on its own.
It covers the whole module. The division walk descends into every declaration form,
not just fn and nested mod bodies — so this program is a compile error rather than a
runtime “division by zero”:
mod ImplDiv do
needs IO.Console
cap no_panic
type Box = Box(Int)
interface Runner(a) do
fn run : a -> Int
end
impl Runner(Box) do
fn run(b) do
match b do
Box(n) -> 100 / n
end
end
end
fn main() do
println(int_to_string(run(Box(2))))
end
end
It’s a compile error. Add the if n != 0 guard and it’s accepted — the walk
reads the body, it doesn’t just distrust it. Top-level lets, interface defaults,
actor handlers, app hooks and test bodies are covered the same way.
A rebound name knows nothing about the old one. Every fact the divisor check reads
is keyed by a bare variable name — the path condition, the parameter’s refinement, a
let’s value — so rebinding that name retires all of them. Each of these is caught
rather than silently accepted:
if d == 0 do 0 else (let d = 0; 10 / d) end -- else side
if d != 0 do (let d = 0; 10 / d) else 0 end -- then side
if d == 0 do 0 else ap(fn d -> 10 / d) end -- lambda parameter
if d == 0 do 0 else match o do Some(d) -> 10 / d ... end -- match binder
(Compressed onto one line each for comparison — March has no ;, so the let really
sits on its own line inside the branch.)
A let, a local fn, a lambda parameter, a let? pattern or a match binder
drops everything known about the outer variable of that name. Note which way this
errs: in the ordinary refinement checker, losing a fact means silence, but here it
means an error, so the retirement is deliberately over-eager. If you need the guard
inside the rebinding scope, re-state it there. Correct code is unaffected —
let d = 5 followed by 10 / d still passes, because the new binding replaces the old
fact rather than merely erasing it.
One asymmetry to know: a refinement on an impl method’s parameter can discharge a
division inside that method’s body only when callers are actually obliged to establish
it — the two passes share one adoption rule so they can’t drift apart. See
the cap verified edges for when that
adoption happens.
Limitations
No refinement system is complete — this one is intentionally a pragmatic slice of dependent typing. Know the edges:
Int,Bool,Float,String(narrowly), records and ADT tags. Predicates over other types aren’t supported.Floatpredicates are comparisons only; float arithmetic inside one is skipped rather than guessed at, and aFloatsitting inside a record or a constructor is opaque. See Bool and Float Refinements and Constructor Tags.- Tag narrowing stops at several ordinary shapes. A
matchon an expression rather than a variable, anaspattern, a pattern that rebinds the name, an ambiguous constructor name, and any rebinding of the name inside the arm all leave the call unchecked rather than reported. See Facts from amatch. - A tag refinement composes only for the constructor you promised. Passing a
{Option(Int) | is_Some(_)}parameter on to a callee wanting the same thing is proved. A different tag is not assumed: withouter(p : {Option(Int) | is_None(_)}), a call needingis_Some(_)is skipped rather than reported, even though the two are exclusive. See Contracts Compose. - A fact doesn’t survive a local
let, for any type.let u = 5thentake_pos(u)against{Int | _ > 0}is skipped. Pass the value directly, or restate it withassert. (This is also why rebinding a refined parameter drops its promise.) - Only preconditions compose automatically. A parameter’s promise reaches
calls in the body; it does not flow into a refined return type the same
way. A postcondition is still checked on every return path, counted by
--refine-report, and escalated bycap verified— see Postconditions — but composing a measure through one (proving a caller’s obligation from a callee’s return contract) is the narrower, structural-recursion-only case described a few bullets down. - Incomplete (by the definite-failure stance). The checker catches values that are definitely wrong and stays silent otherwise. It will not prove every true property; quantified/measure facts in particular sometimes return “unknown” and are skipped. This never produces a false positive, but it does mean some real guarantees go unchecked.
- Higher-order: two shapes are checked, the rest are not. A call made
through a parameter whose declared type carries a refinement —
f : ({Int | _ >= 0}) -> Int— is checked, and so is a call through a local alias of a named refined function (let g = takepostheng(-3)). NOT checked: a callback parameter whose own type is unrefined (soapply(take_n, -3)withapply(f : Int -> Int, x : Int)still passes), inferring a higher-order function’s requirement from its body, dispatch through aninterface/impl, and multi-argument callback types. To constrain a caller today, refine the higher-order function’s own parameter. - Measures see structure, not elements. Element values inside a data
structure are opaque to a measure (
size/len/depthnever inspect them). Measures are single-argument, structurally recursive, and returnInt/Bool. - Only some length spellings are connected to
len. The qualifiedList.length,String.byte_size, and thestring_byte_lengthbuiltin discharge alenobligation; a barelength,String.codepoint_count, andstring_lengthdo not, and a guard written with those leaves the call skipped rather than proved. The connection is also dropped for the whole compilation unit — every prepended stdlib module and everyMARCH_LIB_PATHdependency included — if a single binding anywhere in it could make the name denote something other than the standard library’s own function. See the solver really does connectList.lengthtolen. - Relational postconditions work, within structural recursion. A predicate
that relates a measure across an operation —
size(insert(t, x)) == size(t) + 1— is proven by supplying the induction hypothesis at each recursive call whose argument is a proper component of the matched parameter, then discharging eachmatcharm against the measure’s recursion equations. A body that is a bare constructor application —fn push(t, x) : {Tree | size(_) == size(t) + 1} do Node(t, x, Leaf) end— is proven too, and needs no induction at all: there is no recursive call to hypothesise over, only one unfolding of the measure’s recursion equation. That shape also records its verdict in the obligation ledger, so--refine-reportshows it as attempted rather than absent. Only a postcondition actually proved propagates, so an unprovable one stays legal but tells callers nothing. Still silent: mutual recursion, a recursive call inside a lambda or behind a nestedmatch, and any non-structural recursion. -
A measure whose value is a scalar constructor field never discharges anything. A measure that reads a field out of its constructor —
fn length(v) do match v do PVec(n, _, _, _) -> n end end, the natural way to writelengthfor a container that stores its own count — is accepted, passes the soundness gate, and gets a correct axiom, and yet proves nothing in either direction. When the checker reflects a constructor at a call site, it replaces every field that is not itself a data type with a fresh unconstrained constant (reflect_field): sound for a structurally recursive measure, whose value depends only on tags and sub-measures, and fatal for one whose value is the field. Solength(PVec(3, 0, TrieEmpty, Nil))reaches the solver as an unknownInt, and an obviously in-range index is neither proved nor refuted — it issolver-undecidedand silently accepted. This is whyArray.get/set/popcarry no bounds contract today and stay on thecap no_panicban list instead.The compiler warns at the measure’s definition when it sees this, but the warning fires only on a bare field read (
-> n). A body that merely computes with the erased field —-> n + 1,-> n * 2— is equally inert and draws no warning at all: the check is deliberately narrow, because a broader one flagged bodies like-> 0 * nthat mention the field without depending on it, and a false positive is worse. Silence here does not mean your measure works. If a measure’s value depends on a scalar constructor field in any way, expect it to prove nothing. - A measure over a built-in
Listwith a non-scalar element does not axiomatise.List(Int)is fine;List(SomeAdt)collapses the element to an opaque sort and the measure is never usable. A user-defined list type with the same shape works. This is the first obstacle between this machinery and the stdlib’s HAMT-basedMap. Boolpredicates need an operator form.{Bool | _ == true}is checked; the bare-binder spelling{Bool | not _}is a parse error — write{Bool | _ == false}.- No float special values. There is no
is_nan/is_finitevocabulary, so a predicate cannot mention them (NaN is still modelled correctly inside the solver, which is what keeps correct code from being flagged). - Performance: measures can be slow on a cold cache. Quantified + datatype reasoning is far more expensive per query than plain arithmetic. Verdicts are content-addressed and cached (warm rebuilds are fast), and the cost is isolated to call sites that actually mention a measure — but a cold build of measure-heavy code pays for it. See the flag below.
Practical Rules
- Refine the contract, not the convenience. Add
{Int | _ > 0}where a non-positive value is a genuine bug (a chunk size, an unguarded divisor), not to everyInt. Many March APIs already clamp defensively and have no real precondition. - Guard, then call. A precondition you can’t satisfy with a literal is
discharged by an
if/whenguard right before the call. - Reach for
assertas your lemma. When you know a fact the checker can’t derive,assert(p)makes it available — and documents the assumption. - Annotate measures you’ll reason about. A
@[measure]only earns its keep if a predicate mentions it; keep them total, exhaustive, and structural so they pass the gate.
Appendix: the List.length↔len alias, in full
Skip unless a length guard you expected to work isn’t discharging. Referenced from
Path Sensitivity and
Limitations. This is the exact rule for when a length guard counts as
the len measure.
The solver really does connect List.length to len
The checker treats the qualified List.length as another name for the len measure, so
a guard you’d write anyway — if List.length(ys) > 0 — establishes exactly the fact the
contract {List(Int) | len(_) > 0} is asking for:
fn first_or(ys : List(Int), d : Int) : Int do
if List.length(ys) > 0 do head(ys) else d end -- proved
end
fn broken(ys : List(Int)) : Int do
if List.length(ys) == 0 do head(ys) else 0 end -- compile error
end
The second is a compile error because under len(ys) == 0 the predicate len(ys) > 0
can never hold — a definite failure, which is the bar March requires before it says
anything.
The connection is deliberately narrow, since attaching len’s meaning to the wrong
function is how you’d get a false alarm on correct code. Only the qualified
List.length counts (a bare length is left alone), and only while it still resolves to
the standard library’s own. If your program defines its own List.length — however it
spells the definition: a fn, a module-level let, an extern block, an interface or
impl method — or ships a forked List via MARCH_LIB_PATH, the connection is dropped
and you’re back to the obligation being skipped, quietly, rather than proved.
“Dropped” means dropped for the whole compilation unit, not just the file you’re
editing. The check is syntactic and unit-global: it doesn’t ask whether the competing
binding could ever win at your call site, because answering that needs a resolver this
pass doesn’t have. One genuine competitor anywhere in the unit — including inside a
MARCH_LIB_PATH dependency you never opened, and remembering that the compiler prepends
the entire standard library to every compilation — disables the alias program-wide.
That’s a real cost, and it’s the direction the checker errs in on purpose:
over-withdrawing loses a proof (silence), while under-withdrawing would put a wrong fact
in the assumption set and flag correct code.
The withdrawal rules for the specific import forms:
- A selector-less
use Foo.List(importing the module itself, not a member of it) resolves its target:use Analytics.ListwhereAnalytics.Listonly has asizefunction leavesList.lengthconnected tolen; ausewhose target really does definelengthwithdraws it. alias Foo.List as Listand a named import (use Foo.{List}) withdraw the alias unconditionally.- A glob import (
import Foo,use Foo.*) withdraws the alias only ifFooactually provides a competingList, resolved by walking the unit’s own module structure; if the glob’s target can’t be resolved, it still withdraws. - Either half of the guard applies only to the program’s own bindings, never the standard library’s own. The two conditions are ANDed, so a glob withdraws only when it is your code and its target really carries a competitor.
The same for strings — but only the byte-valued names
len measures a String too, and the same connection is made for String.byte_size
and the string_byte_length builtin:
fn slug(s : {String | len(_) > 0}) : String do String.slice(s, 0, 1) end
fn label(t : String) : String do
if String.byte_size(t) > 0 do slug(t) else "?" end -- proved
end
Swap the guard for String.byte_size(t) == 0 and that call becomes a compile error, the
same way it does for lists.
The catch is that len on a String counts bytes, so only byte-valued names get this
treatment. String.codepoint_count counts codepoints — it returns 1 for "é" where
String.byte_size returns 2 — and is left alone. So is string_length: it happens to be
a byte length today, but the name suggests characters, and a connection made on a name
that might later be corrected is a bug waiting to happen. Reach for String.byte_size in
a guard; it says what it means.
A qualified spelling inside the predicate itself
Everything above is about a guard — ordinary code, outside the {...}. Writing the
qualified name inside the braces enforces the same contract as the bare len:
fn inner(xs : {List(Int) | List.length(_) > 0}) : Int do 0 end
fn inner(xs : {List(Int) | len(_) > 0}) : Int do 0 end
A refinement predicate is not run through the general expression desugarer the way a
function body is — no pipe desugaring, no multi-head-fn desugaring — but the one
transformation that matters here, flattening a module-path call head (List.length(_))
into the dotted form the len alias keys on, runs over every TyRefine predicate
(parameter, return, let-annotation — top-level and block-level alike — and
record/variant field types alike). When the alias is live — no competing List.length
in scope — the qualified spelling means exactly what len means, so inner([]) is
rejected as a genuine precondition violation.
If a unit has withdrawn the alias by defining its own competing List.length, the
qualified spelling enforces nothing (correctly — the alias genuinely doesn’t hold there),
and the checker warns, still recommending the bare len(_) spelling:
List.lengthis a qualified call inside a refinement predicate. This spelling is never reflected here, so the refinement enforces nothing. Use the bare spellingleninstead.
The same applies to String.byte_size. Two shapes remain genuinely unhandled and still
warn/stay silent: a record field call ({Cfg | c.cb(1) > 0}, never treated as a
qualified call) and a receiver that is itself a call (f(x).g(y), not rendered as a
path).
Appendix: Where Refinements Resolve
This is a plumbing detail about how the checker looks up which function a call refers to, not something you need to know to start using refinements.
Refinement checking follows the same name resolution as the type checker:
direct named calls, across modules, through alias and use:
mod App do
use Lib.{take_pos} -- imported name resolves to Lib.take_pos
alias Lib.Inner as I -- alias resolves to Lib.Inner.*
fn run() : Int do
take_pos(-1) -- error: resolved + checked against Lib's precondition
I.helper(-1) -- error: alias-qualified call checked too
end
end
A bare call resolves at the scope that actually owns it: a use written
inside a nested module is consulted before the checker falls outward to an
enclosing module’s own definition of the same name, matching how the call
really dispatches. An enclosing module’s use, on the other hand, still loses
to a nested module’s own definition of the name — an import never reaches
in and overrides a local one.
mod Outer do
fn take_pos(n : {Int | n >= 0}) : Int do n end -- Outer's own contract
mod Inner do
use Lib.{take_pos} -- Lib's take_pos, unrefined
fn go() : Int do
take_pos(-1) -- resolves to Lib.take_pos, NOT Outer's — no error
end
end
end
Checking this call against the enclosing App.take_pos instead would be a false
positive on correct code, since Inner.go never actually calls it — so the checker
resolves it the way the call really dispatches.
Appendix: The --no-measure-axioms Flag
Also a niche knob — only relevant if a measure-heavy build feels slow.
Pass --no-measure-axioms to reflect @[measure] functions symbolically
instead of axiomatising them. This skips the datatype/quantifier reasoning (and
the soundness gate), trading structural measure reasoning for speed. It changes
only diagnostics, never the compiled artifact. Refinement checking of plain
Int/Bool predicates is unaffected and always cheap.
march --check --no-measure-axioms app.march
Next Steps
- Type System — the types refinements attach to
- Linear Types — the other compile-time safety layer
- Pattern Matching —
matchguards feed path sensitivity