Laurel User Guide

4.8. Constrained types🔗

A constrained type (a refinement type) is a base type narrowed by a predicate. It is introduced with the constrained keyword and has four parts: a name, a value binder together with its base type, a where predicate that values of the type must satisfy, and a witness value that proves the type is inhabited.

constrained nat = x: int where x >= 0 witness 0

This declares nat as the integers that are at least zero. The binder x ranges over the base type int, x >= 0 is the constraint, and 0 is the witness — a concrete value Laurel checks against the predicate to be sure the type is not empty. A witness that fails its own predicate is rejected:

constrained bad = x: int where x > 0 witness -1
// error: the witness -1 does not satisfy x > 0

A constrained type is checked at every point where a value acquires the type, and it is available as an assumption at every point where a value is known to have the type. The rest of this section walks through those points.

4.8.1. Inputs🔗

A parameter of constrained type contributes a precondition. Callers must prove the argument satisfies the constraint, and in exchange the body may assume it.

constrained nat = x: int where x >= 0 witness 0

procedure inputAssumed(n: nat)
  opaque
{
  assert n >= 0   // holds: the nat constraint is assumed for inputs
};

Passing an argument that cannot be shown to satisfy the constraint fails at the call site, exactly like any other precondition.

4.8.2. Outputs🔗

An output of constrained type contributes a postcondition. The procedure must establish the constraint on its result, and callers may then assume it.

constrained nat = x: int where x >= 0 witness 0

procedure outputValid() returns (r: nat)
  opaque
{
  r := 3          // ok: 3 satisfies x >= 0
};

Returning a value that violates the constraint fails as a postcondition:

constrained nat = x: int where x >= 0 witness 0

procedure outputInvalid() returns (r: nat)
  opaque
{
  r := -1         // error: postcondition does not hold (-1 is not a nat)
};

Because the constraint travels with the output, a caller of an opaque procedure learns it from the contract alone, without seeing the body:

constrained nat = x: int where x >= 0 witness 0

procedure opaqueNat() returns (r: nat)
  opaque;

procedure callerAssumes()
  opaque
{
  var v: int := opaqueNat();
  assert v >= 0   // holds: opaqueNat's result is a nat
};

4.8.3. Local variables🔗

Initializing or assigning to a constrained-typed local asserts the constraint on the assigned value. Both the initial value and every later reassignment are checked.

constrained nat = x: int where x >= 0 witness 0

procedure assignLocal()
  opaque
{
  var y: nat := 5;   // ok
  y := -1            // error: assignment violates the nat constraint
};

A constrained-typed local that is declared without an initializer is treated as an arbitrary value that satisfies the constraint: the constraint is assumed, but nothing more. In particular you cannot assume it holds the witness value.

constrained nat = x: int where x >= 0 witness 0

procedure uninitialized()
  opaque
{
  var y: nat;
  assert y >= 0   // holds: the constraint is assumed
};

4.8.4. Quantifiers🔗

When a quantifier binds a variable of constrained type, the constraint is injected into the body so the bound variable ranges only over values of the type. For forall the constraint becomes an antecedent; for exists it becomes a conjunct.

constrained nat = x: int where x >= 0 witness 0

procedure quantifiedNat()
  opaque
{
  // provable only because n >= 0 is injected: false over all integers
  assert forall(n: nat) => n + 1 > 0;
  // 42 witnesses the existential and satisfies n >= 0
  assert exists(n: nat) => n == 42
};

4.8.5. Nested constrained types🔗

A constrained type may refine another constrained type. The constraints then compose: a value of the inner type must satisfy its own predicate and every predicate up the chain.

constrained even = x: int where x % 2 == 0 witness 0
constrained evenpos = x: even where x > 0 witness 2

procedure nested(x: evenpos)
  opaque
{
  assert x > 0;      // evenpos's own constraint
  assert x % 2 == 0  // inherited from even
};

Because algebraic datatypes can be encoded as constrained types over a base type, this composition is what lets a value carry several layers of invariant at once.