Refinement Types vs Full Dependent Types: The Middle Ground Nobody Talks About HOT โ˜…

Started by SMTKitty  ยท  112 replies  ยท  4,891 views  ยท  Last post: 2 hours ago  ยท  Subforum: Type Theory & Formal Verification
๐Ÿฑ
SMTKitty
LIA Queen
Posts: 2,341
OP
#1

OK so I feel like every discussion in this subforum eventually collapses into a binary: you're either writing real proofs in Agda/Coq/Lean, or you're a pleb who just uses plain types and runtime asserts. But there's an entire tier of tooling that gets almost zero forum airtime: refinement type systems โ€” specifically LiquidHaskell, F*, and Dafny.

My thesis: these tools occupy a genuinely useful middle ground, and dismissing them as "dependent types lite" misunderstands what they're actually offering. Let me make the case.

The core idea of refinement types is simple: decorate your base types with logical predicates. Instead of just Int, you get {v:Int | v > 0}. Instead of just a list, you get a list of known length or sorted order. Here's a minimal LiquidHaskell example:

-- LiquidHaskell -- Refinement type: second arg must be nonzero {-@ safeDiv :: Int -> {v:Int | v /= 0} -> Int @-} safeDiv :: Int -> Int -> Int safeDiv x y = x `div` y -- Positive integers only {-@ type Pos = {v:Int | 0 < v} @-} -- A sorted list alias {-@ type IncrList a = [a]<{\x y -> x <= y}> @-} {-@ insertSort :: (Ord a) => [a] -> IncrList a @-} insertSort :: Ord a => [a] -> [a] insertSort [] = [] insertSort (x:xs) = insert x (insertSort xs)

The key insight: the checker outsources all proof obligations to an external SMT solver (typically Z3). You don't write proof terms. You don't wrestle with refl and cong. The solver just... decides.

This is the trade-off nobody wants to talk about: refinement types are decidable in a way that full dependent types aren't. The predicate language is constrained to what SMT can handle โ€” linear arithmetic, set operations, uninterpreted functions โ€” but within that fragment, verification is fully automatic.

I've been using LiquidHaskell on a ~6k line project for three months. The overhead is real but manageable. I'd estimate maybe 1โ€“2 annotation lines per 50 lines of code for the hot paths. Is it Agda? No. Is it 10x better than nothing? Absolutely.

What do you all think? Is this the pragmatic sweet spot, or am I fooling myself?

sig: "The SMT solver knows. Trust the SMT solver." โ€” me, lying to myself at 2am
๐Ÿพ
VectorPawz
ฯ€-calculus Enjoyer
Posts: 891
#2

Came here to say that F* is doing something even more interesting โ€” it's genuinely both. It has a two-level verification story:

-- F* (simplified) (* Refinement-style: SMT decides this *) val safeDiv: x:int -> y:int{y <> 0} -> int let safeDiv x y = x / y (* Going deeper: explicit proof terms *) val lemma_div_positive: x:int{x > 0} -> y:int{y > 0} -> Lemma (x / y >= 0) let lemma_div_positive x y = () (* SMT handles this automatically *) (* But for harder things you write tactics: *) val complex_lemma: ... -> Lemma (...) let complex_lemma ... = Classical.forall_intro (...)

F* doesn't force you to choose. When SMT can handle it, you get automation. When it can't, you drop down to explicit proof terms. That's the design I actually want.

The meta-type theory of F* is also more expressive than pure refinement systems because it includes Tot, GTot, Lemma effect labels that let you reason about effects along with correctness properties.

๐Ÿ˜บ
PracticalPurr
Verified Pragmatist
Posts: 3,102
Veteran
#3

Been saying this for years in the dependent types in industry thread. The academic community's obsession with proof terms has genuinely set back industrial adoption of formal methods by a decade.

SMTKitty wrote:
Is this the pragmatic sweet spot, or am I fooling myself?

You're not fooling yourself. Here's the actual hierarchy of what you're buying:

Level 0: No types beyond primitives. Runtime errors everywhere. (JavaScript engineers, look away.)
Level 1: Standard Hindley-Milner. Catches a lot but misses value-level constraints.
Level 2: Refinement types (LiquidHaskell, F* SMT layer, Dafny). This is where 90% of real bugs live and where automation is still feasible.
Level 3: Full dependent types (Agda, Coq, Lean). Maximally expressive, requires significant proof engineering effort.
Level 4: HoTT and beyond. Here there be dragons.

The dirty secret is that most "critical" properties โ€” memory safety, arithmetic overflow, array bounds, protocol state invariants โ€” fall cleanly in Level 2's expressiveness. You don't need inductive families and universe polymorphism to prove that your buffer index is in bounds.

Dafny in particular has a really compelling story here. The SMT backend (also Z3) handles most invariants automatically, and the syntax stays close to imperative code. I've introduced it to two different teams and seen real engineers actually use it within a week.

sig: "Perfect is the enemy of verified." โ€” PracticalPurr, constantly
๐Ÿˆ
AgdaCat
Totality Enforcer
Posts: 5,778
Veteran
#4

I'll be the villain. This "pragmatic sweet spot" framing papers over some fundamental issues.

โš ๏ธ Strong opinion incoming. I'm not dunking on people using these tools โ€” I'm pushing back on the framing that SMT-backed verification is categorically safer than full dependent types.

Problem 1: SMT solvers are black boxes with opacity problems.

When Z3 says your program is verified, what exactly does that mean? You've established that a certain quantifier-free formula over linear arithmetic is unsatisfiable. Great. But did your refinement annotations actually capture what you wanted to prove? The spec-implementation gap is just moved to the annotation layer.

In Agda, your proof term is the proof. The kernel checks it. There's no oracle making decisions you can't inspect. When something is admitted in Agda, you see the postulate keyword staring at you. In LiquidHaskell, Z3 timeouts just... silently succeed sometimes?

Problem 2: The predicate language boundaries are sharp and ugly.

Try expressing in LiquidHaskell that reverse (reverse xs) == xs. Go on. I'll wait.

-- Agda: clean, structural induction rev-involutive : โˆ€ {A : Set} (xs : List A) โ†’ reverse (reverse xs) โ‰ก xs rev-involutive [] = refl rev-involutive (x โˆท xs) = begin reverse (reverse (x โˆท xs)) โ‰กโŸจ ... โŸฉ x โˆท xs โˆŽ

The moment you need to reason about recursive functions and their fixpoints, SMT hits a wall. reverse is not in any SMT decidable theory. You need Refinement Reflection (which LiquidHaskell now has, credit where due) but at that point you're writing explicit equational proofs anyway โ€” you've just reinvented a weaker Agda with worse error messages.

Problem 3: False confidence. "Level 2 covers 90% of real bugs" is a claim I'd want to see rigorous support for. It's also exactly the kind of thing that leads teams to ship underspecified systems with a green CI badge.

sig: `Set : Setโ‚` and I am NOT sorry about it
๐Ÿฑ
SMTKitty
LIA Queen
Posts: 2,341
OP
#5
AgdaCat wrote:
Try expressing in LiquidHaskell that reverse (reverse xs) == xs. Go on. I'll wait.

This is a fair point but I think it's also the crux of why you're talking past the use case. Who is trying to prove rev-involutive in production code? That's a correctness theorem about the implementation of reverse itself, not about the properties of data flowing through your system.

The properties I actually care about on my project:

  • This index is always in bounds
  • This integer is always positive
  • This list has exactly N elements when passed to this function
  • This pointer is not null here
  • These two lengths are equal at this call site

All of these are linear arithmetic over sizes and indices. Z3 eats them for breakfast. The annotation overhead is low and the feedback is fast (seconds, not minutes).

You're not wrong that SMT is a black box. But so is GHC's type checker to most people. The question is whether you trust the system, and LiquidHaskell has a solid theoretical foundation and has been used to verify millions of lines of real Haskell libraries. I'll take the tradeoff.

๐ŸฆŠ
DafnyDreamer
Imperative Catgirl
Posts: 442
#6

Can I bring Dafny into this? Because I think it shows the refinement model at its most industrial-friendly. Here's a verified binary search:

// Dafny method BinarySearch(a: array<int>, key: int) returns (index: int) requires Sorted(a) ensures 0 <= index < a.Length ==> a[index] == key ensures index == -1 ==> forall k :: 0 <= k < a.Length ==> a[k] != key { var lo, hi := 0, a.Length; while lo < hi invariant 0 <= lo <= hi <= a.Length invariant forall i :: 0 <= i < lo ==> a[i] < key invariant forall i :: hi <= i < a.Length ==> a[i] > key { var mid := lo + (hi - lo) / 2; if a[mid] < key { lo := mid + 1; } else if a[mid] > key { hi := mid; } else { return mid; } } return -1; }

Look at this. It's practically pseudocode. The requires/ensures/invariant annotations read like English. A software engineer with zero formal methods background can understand what's being verified and write these specs themselves after an afternoon of tutorials.

That's the win. It's not about what you can prove. It's about the ratio of proof engineering effort to verified properties acquired.

๐Ÿˆ
AgdaCat
Totality Enforcer
Posts: 5,778
Veteran
#7
DafnyDreamer wrote:
A software engineer with zero formal methods background can understand what's being verified and write these specs themselves after an afternoon of tutorials.

I concede that the Dafny example is genuinely compelling for imperative code. The loop invariant + pre/postcondition style is essentially structured Hoare logic and there's solid theory behind it.

My concerns are more about the failure modes than the happy path. Specifically:

SMT decidability cliff: everything is smooth until you hit a property the SMT can't handle, and then you get either a timeout or a spurious counterexample that you have to debug without knowing whether it's a real bug or a solver incompleteness artifact. That debugging experience is genuinely painful.

On the opacity point: I'll grant that GHC's type checker is also not fully transparent to end users. But the LiquidHaskell trusted computing base includes Z3 itself โ€” a massive, complex piece of software. The Agda kernel is ~2k lines of Haskell. These are meaningfully different trust stories.

That said โ€” and I want to be fair โ€” I think F* has done the best job of genuinely integrating both worlds. The Lemma effect in F* means you can escalate from "Z3 handles it" to "I'll write a proof term" fluidly. That's the right design direction.

๐Ÿ’ก If you haven't read the F* paper "Dependent Types and Multi-Monadic Effects in F*" (Swamy et al., POPL 2016), it's worth understanding how they unify refinement types and full dependent types under a single effect system.
๐ŸŒฟ
KernelKitten
Systems Verified
Posts: 1,203
#8

Real world data point: I work on a cryptographic library and we use F* (the SMT layer) for most of our verification. The properties we verify:

  • Buffer bounds everywhere (zero runtime bounds checks in hot paths)
  • Constant-time execution for crypto ops (modeled via taint types)
  • Integer overflow/underflow for field arithmetic
  • Memory aliasing conditions

All of this lives comfortably in the SMT-decidable fragment. For the handful of deep algebraic properties (commutativity of field operations, group law correctness), we write the explicit proofs. It's maybe 5% of the annotation burden.

The thing that AgdaCat is missing (I think) is that for most industrial verification targets, the hard part is not the proof difficulty โ€” it's the annotation discipline. Getting engineers to write specs at all is the battle. Refinement types lower that barrier dramatically.

A verified crypto library with refinement types is better than an unverified one. This should not be controversial.

sig: formally verified and slightly caffeinated
๐ŸŽญ
TypeClassMeow
Monad Whisperer
Posts: 667
#9

Something that hasn't been mentioned: the inference story for refinement types is genuinely impressive and has no analog in full DT systems.

In LiquidHaskell, most refinements propagate automatically via Liquid Typing โ€” you often don't need to annotate intermediate expressions at all. The constraint inference fills in the gaps. Try getting that kind of inference in Agda for anything non-trivial.

-- LiquidHaskell: inference fills in the blanks {-@ type Nat = {v:Int | 0 <= v} @-} {-@ type Pos = {v:Int | 0 < v} @-} -- annotate the top level... {-@ factorial :: Nat -> Pos @-} factorial :: Int -> Int factorial 0 = 1 factorial n = n * factorial (n - 1) -- ...and LH infers that (n-1) :: Nat internally -- no annotation needed at recursive call site!

The n - 1 :: Nat at the recursive site is inferred because LH has the context n :: Nat and n /= 0 from the pattern match, so n - 1 >= 0 is automatically derived. SMT handles that arithmetic implication without any user-provided hint.

This is why I say the annotation experience is fundamentally different from dependent types. In Agda you'd be passing around Fin indices or explicit proofs. Here it just... flows.

๐Ÿ›ก๏ธ
nyanpasu_mod
Forum Moderator
Posts: 9,041
MOD
#10

Excellent thread, marking as Featured. Keeping an eye on the AgdaCat/PracticalPurr dynamic โ€” you two have had productive debates before, let's keep it that way ๐Ÿพ

Related resources in the wiki if people want background:

โœจ
CoqCatGirl
Coq de Guerre
Posts: 2,887
#11

Jumping in from Team Full-DT to partially concede the point.

I've spent years in Coq and I think there's a real spectrum here that the "DT vs refinements" framing flattens. The distinction that matters most to me is proof-term verification vs. oracle-based verification:

Proof-term verification (Agda/Coq/Lean): The type checker is a proof checker. There's a tiny trusted kernel. Every claim is backed by a checkable derivation. Soundness follows from the metatheory.

Oracle-based verification (LiquidHaskell/Dafny): The type checker generates constraints and queries an SMT solver. Soundness depends on the SMT solver being sound for the fragment used AND the constraint generation being correct. That's a larger TCB.

This doesn't mean oracle-based is worse โ€” it's a different point on the automation/trust tradeoff curve. For most engineering contexts, the SMT oracle story is fine. For high-assurance aerospace or crypto where you need formal guarantees you can independently audit, I'd want proof terms.

The real exciting thing: Lean 4 + native SMT integration might be giving us the best of both worlds. You get a kernel-checked proof, but the SMT oracle helps fill in the tedious bits automatically.

sig: `exact rfl` solves most of life's problems
๐ŸŠ
TerminationKat
Decreasing Measure
Posts: 319
#12

One thing I haven't seen mentioned: LiquidHaskell also handles termination checking as part of its refinement story. It's actually kind of elegant:

-- LiquidHaskell termination via refinements -- LH checks that (sz xs) strictly decreases {-@ merge :: IncrList a -> IncrList a -> IncrList a / [len xs + len ys] @-} merge [] ys = ys merge xs [] = xs merge (x:xs) (y:ys) | x <= y = x : merge xs (y:ys) | otherwise = y : merge (x:xs) ys

The / [len xs + len ys] annotation provides the termination metric โ€” the SMT checks that this measure strictly decreases at each recursive call. You get totality proofs "for free" with a one-line hint per function, roughly.

Agda does this too obviously, but Agda's termination checker is structural and will reject things that LH accepts via well-founded metrics. Different tradeoffs.

๐Ÿ˜บ
PracticalPurr
Verified Pragmatist
Posts: 3,102
Veteran
#13
AgdaCat wrote:
I'll grant that GHC's type checker is also not fully transparent to end users.

Progress! The AgdaCat acknowledges a point. Mark this day.

I want to synthesize what I think we're actually agreeing on underneath the disagreement:

  1. SMT-backed refinement types are sound for their stated properties (assuming Z3 soundness, which is extensively tested)
  2. The decidable fragment is a real limitation that matters for some verification goals and not others
  3. For engineering teams, the annotation UX and learning curve are decisive factors that favor refinement systems
  4. For high-assurance proofs where independent auditability matters, proof-term systems give you something SMT can't
  5. F* is doing something interesting by trying to dissolve this binary

The thread title says "the middle ground nobody talks about" โ€” I'd argue this conversation is evidence that the community is starting to talk about it more. The question of where to set the verification dial for a given project is an engineering judgment, not a matter of principle.

Refinement types are not dependent types. They're not trying to be. They're trying to be useful, and for a wide class of real software properties, they succeed.

sig: "Perfect is the enemy of verified." โ€” PracticalPurr, constantly

๐Ÿ“ Post Reply

You must be logged in to post. No account? Register here.

Preview | Attach code block | Quote selected