Refinement types for PureScript
You write ordinary PureScript. A sibling .lps file refines the
types of the functions you care about. lps verify proves the
refinements hold — or hands you a counterexample.
No compiler fork; it reads the CoreFn the compiler
already emits and discharges its obligations with Z3.
Demo.purs — ordinary PureScript
module Demo where
abs :: Int -> Int
abs n = if n > 0 then n else 0 - n
bad :: Int -> Int
bad n = n - 1
Demo.lps — the refinement layer
type Nat = { v : Int | v >= 0 }
abs :: Int -> Nat
bad :: Int -> Nat
Both functions typecheck for the compiler. Only one satisfies its refinement — and when the tool disagrees, it says exactly why, at the exact span, with a value that breaks it.
A PureScript type says a function returns an Int. A
refinement type says which Int: one that is
>= 0, or a valid index into an array, or a MIDI-safe
0 <= v && v < 128. The invariants you would
otherwise keep in comments, tests, or your head become part of the type —
and get checked on every build.
The refinements live in a separate file, so your source stays ordinary PureScript that any toolchain compiles. Refine only the functions that matter; the rest are simply unchecked.
clamp lo hi x doesn't merely return an
Int — it returns one in [lo, hi], and the
spec carries the dependency between arguments:
clamp :: { lo : Int }
-> { hi : Int | hi >= lo }
-> Int
-> { v : Int | v >= lo && v <= hi }
When a proof fails you get a concrete input that breaks the claim — the empty array for an index that assumed non-emptiness:
Honesty is a feature. This verifies a fragment:
first-order Int / Boolean / measured-data code.
Everything outside the fragment reports
Unsupported, never a silent
pass. Higher-order functions, type classes, and row-polymorphic records
are roadmap, not shipped. And an
assume spec is trusted, not checked — a stated axiom
for imported or FFI functions, taken on faith and marked as such wherever
it appears.
Requirements. purs 0.15.x,
spago@next, node, and a z3 binary on the PATH.
make examples # compile examples/ to CoreFn (spago backend trick)
make verify # verify every example module that has a .lps spec
make test # golden test — the verdict table on this site
make bundle # standalone bin/lps.mjs (node, no spago needed)
Direct invocation over any spago project's build output:
lps verify --output <dir> <ModuleName> [--spec <file.lps>]
[--include <file.lps>]... [--smt2-dir <dir>]
lps verify-all --output <dir> [--include <file.lps>]... [--smt2-dir <dir>]
--include lib/prelude.lps prepends trusted specs for
min / max / abs / signum
/ clamp on Int. --smt2-dir dumps
every obligation as a replayable .smt2 artifact.