Refinement Types
Python's built-in type hints — int,
str,
list[int] —
tell you the shape of data. But they can't express "an integer
that is non-negative" or "a list that is sorted." Refinement types close
this gap: they pair a base type with a logical predicate
that every value of that type must satisfy.
Deppy lets you write these predicates as ordinary Python lambdas and verifies them automatically with Z3 — without modifying the code they describe. This chapter shows you how.
Base Types as Refinements
Every Python type is already a refinement type — it just has a trivially
true predicate. When Deppy sees a plain int
annotation, it treats it as:
# Internally: int ≡ refined(PyIntType(), "True", "x")
def successor(n: int) -> int:
return n + 1
The same holds for str,
float,
bool, and every other built-in.
This means refinement types are a strict generalisation of Python's
existing type system — all your existing annotations keep working.
The refined Constructor
To add a predicate, use refined.
It takes a base type, a string predicate (so the
kernel can both Z3-check it and emit it into the Lean
certificate), and the bound-variable name:
from deppy import refined
from deppy.types import PyIntType, PyListType
# Natural numbers: integers ≥ 0
Nat = refined(PyIntType(), "x >= 0", "x")
# Positive integers: strictly greater than 0
Pos = refined(PyIntType(), "x > 0", "x")
# Even integers
Even = refined(PyIntType(), "x % 2 == 0", "x")
# Odd integers
Odd = refined(PyIntType(), "x % 2 == 1", "x")
# Non-empty lists
NonEmpty = refined(PyListType(), "len(xs) > 0", "xs")
Once defined, use them exactly like regular type annotations:
def double(n: Nat) -> Even:
return n * 2
def factorial(n: Nat) -> Pos:
if n == 0:
return 1
return n * factorial(n - 1)
Deppy reads these annotations from your source and encodes the predicates as Z3 constraints. It then proves — at verification time, not at runtime — that the function body satisfies the refinement on the return type given the refinement on the parameters.
@guarantee for Postconditions
The @guarantee decorator attaches a
postcondition to a function. Inside the predicate string, the special
name result refers to the return value:
from deppy import guarantee
@guarantee("result >= 0")
def absolute(x: int) -> int:
if x >= 0:
return x
return -x
You can reference parameters and the result together:
@guarantee("len(result) == len(a) + len(b)")
def concat(a: list[int], b: list[int]) -> list[int]:
return a + b
Deppy verifies each guarantee by encoding it as a Z3 assertion and checking that it holds for every execution path through the function.
@guarantee vs @ensures.
As explained in Getting
Started, @guarantee takes a natural-language string
and is parsed into Z3 when possible. For a fully formal,
machine-checkable predicate, use @ensures instead:
@ensures(lambda x, result: result >= 0)
def absolute(x: int) -> int: ...
Both are valid; @ensures guarantees Z3-level
verification with no NL ambiguity.
@assume for Preconditions
Use @assume to state what must be
true when a function is called. Preconditions create
proof obligations at every call site — Deppy checks
that every caller satisfies the assumption.
from deppy import assume
@assume("n > 0")
def reciprocal(n: int) -> float:
return 1.0 / n
Now any call to reciprocal must
provably pass a positive integer. If you write
reciprocal(0), Deppy reports a
verification error — before the code ever runs.
# ✓ Deppy accepts — 42 > 0 is trivially true
x = reciprocal(42)
# ✗ Deppy rejects — cannot prove 0 > 0
try:
y = reciprocal(0)
except (ZeroDivisionError, ValueError):
pass # precondition violated at runtime
# ✓ Deppy accepts — the if-guard establishes n > 0
def safe_call(n: int) -> float:
if n > 0:
return reciprocal(n)
return 0.0
Refinement Subtyping
If the predicate of type A implies the predicate of type B, then A is a subtype of B. Deppy checks this via Z3 implication queries.
# Subtyping chain: Pos <: Nat <: int
#
# x > 0 ⟹ x >= 0 ⟹ True
#
# Every Pos is a Nat, and every Nat is an int.
This means you can pass a Pos wherever a Nat
is expected, and a Nat wherever an int
is expected — Deppy accepts these automatically:
def increment(n: Nat) -> Pos:
return n + 1
# Passing Pos where Nat is expected — subtyping makes this safe
p: Pos = 5
q: Pos = increment(p) # ✓ accepted
Visually, the subtyping lattice looks like this:
int (λx. True)
/ \
Nat Neg
(x ≥ 0) (x < 0)
/ \
Pos Zero
(x > 0) (x == 0)
Combining Refinements
You can build richer types by combining predicates with logical connectives:
Intersection (conjunction)
from deppy.types import PyIntType, PyFloatType, PyStrType, PyListType
# A percentage: integer between 0 and 100 inclusive
Percentage = refined(PyIntType(), "x >= 0 and x <= 100", "x")
# A probability: float in [0.0, 1.0]
Probability = refined(PyFloatType(), "x >= 0.0 and x <= 1.0", "x")
# A non-empty string of at most 255 characters
ShortStr = refined(PyStrType(), "len(s) > 0 and len(s) <= 255", "s")
Complex list predicates
# Sorted list: every adjacent pair is in non-decreasing order
# (also available as ``Sorted()`` from deppy)
SortedList = refined(
PyListType(element_type=PyIntType()),
"all(xs[i] <= xs[i+1] for i in range(len(xs)-1))",
"xs",
)
# Unique list: no duplicates
UniqueList = refined(
list[int],
lambda xs: len(xs) == len(set(xs))
)
Worked Examples
Safe Division
from deppy import assume, guarantee
@assume("b != 0")
@guarantee("result * b == a")
def safe_div(a: int, b: int) -> int:
return a // b
The @assume prevents
division-by-zero; the @guarantee
states that integer division is exact when a is divisible by
b. Deppy will flag cases where the guarantee cannot hold
(e.g., safe_div(7, 2)
produces 3, and
3 * 2
!= 7).
Head of a Non-Empty List
@guarantee("result == xs[0]")
def head(xs: NonEmpty) -> int:
return xs[0]
Because xs has type NonEmpty, we know
len(xs) > 0,
so indexing at position 0 is safe. Deppy proves the guarantee
trivially.
Clamping a Value
@assume("lo <= hi")
@guarantee("lo <= result and result <= hi")
def clamp(x: int, lo: int, hi: int) -> int:
if x < lo:
return lo
if x > hi:
return hi
return x
Deppy checks each branch independently. In the first branch,
x < lo so the return is
lo, which satisfies
lo <= lo and
lo <= hi (from the assumption).
The other branches follow similarly.
Inserting into a Sorted List
@guarantee("len(result) == len(xs) + 1")
def sorted_insert(xs: SortedList, v: int) -> SortedList:
for i in range(len(xs)):
if v <= xs[i]:
return xs[:i] + [v] + xs[i:]
return xs + [v]
This function both maintains the SortedList refinement on
its return type and satisfies the length guarantee. Deppy verifies
both properties.
How Deppy Checks Refinements
Under the hood, Deppy translates each refinement predicate into a Z3 formula. Verification proceeds in three steps:
-
Extract — Deppy reads your Python source and collects
all
refinedtypes,@assumepreconditions, and@guaranteepostconditions. -
Encode — Each predicate is translated into a Z3
expression. For example,
lambda x: x >= 0becomesx >= 0in Z3's integer theory. -
Verify — Deppy asks Z3 to prove that, given the
preconditions, the postcondition holds for every path. If Z3 returns
unsat(no counterexample exists), the refinement is verified.
Your Python code is never modified or instrumented. Deppy operates as a sidecar verifier: it reads your annotations, builds proof obligations, and reports results — all without touching your runtime code.
Refinements vs. Runtime Assertions
assert
statements serve different purposes. An assert
checks a condition at runtime and crashes if it fails. A refinement type
is checked statically by Deppy — if verification succeeds,
the property is guaranteed for all inputs, not just
the ones you happened to test. You never need to scatter
assert statements through your
code; the type system does the work.
Lambda Limitations
import, or modify external state
will cause a verification error. Stick to arithmetic, comparisons,
len(), indexing, all(), and
any().
Naming Your Refinements
Nat, Pos, SortedList) rather
than inlining the refined(...) call in every annotation.
Named types are easier to read, easier to reuse, and produce clearer
verification error messages. Compare:
# Hard to read
def f(x: refined(PyIntType(), "x >= 0 and x <= 100", "x")) -> int: ...
# Much better
Percentage = refined(PyIntType(), "x >= 0 and x <= 100", "x")
def f(x: Percentage) -> int: ...
Exercise
ValidEmail for strings that contain both
'@' and '.'. Then write a function
domain(email: ValidEmail) -> str
that extracts the domain part (everything after the @),
with a @guarantee that the result
contains a '.'.
Show solution
from deppy import refined, guarantee
ValidEmail = refined(
str,
lambda s: '@' in s and '.' in s
)
@guarantee("'.' in result")
def domain(email: ValidEmail) -> str:
return email.split('@')[1]
Deppy verifies this in two steps. First, because
email has type ValidEmail, it knows
'@' in email
and '.' in email.
Second, since the '.' must appear somewhere in the string
and the '@' splits the string into a local part and a
domain, the domain part contains the '.' (assuming
standard email format where the dot is in the domain). For more
complex string reasoning, Deppy may invoke the LLM oracle as a
fallback — see Trust &
Verification Levels.