2. Handling Emptiness: When a Type Has No Values
In the first tutorial, we used Gen1, which is a guarantee—a promise—that a value can always be generated. This works
perfectly for types like Nat or String that always have inhabitants.
But what happens when a type might be uninhabited (have no values at all) under certain conditions?
This is a common scenario in dependently-typed programming. A perfect example is Fin n, the type of natural numbers from 0 up to n-1.
Fin 3has three inhabitants:0,1, and2.Fin 1has one inhabitant:0.But what about
Fin 0? It asks for a number in the range0to-1. There are no such numbers. This type is uninhabited.
It is impossible to write a generator that produces a value of type Fin 0, because none exist. Our testing library must be able to handle this
gracefully. In this tutorial, you will learn how to write safe generators for types that might be empty. You will build a correct generator for Fin n
and see how to handle its results safely.
Prerequisites
This tutorial assumes you have completed Installation and First Steps and the first tutorial, “The Generator Monad”.
Step 1: Discovering the Problem
Let’s start by trying to write a generator for Fin n using only the tools we know from the first tutorial.
Create a new file named EmptyTutorial.idr
Add the following code to it. We need to import Data.Fin along with our usual testing modules
import Test.DepTyCheck.Gen
import Data.Fin
import System.Random.Pure.StdGen
Now, try to write a Gen1 generator for Fin n. We can handle the case where n is greater than zero, but the Z (zero) case presents a major
problem.
-- This is an INTENTIONALLY INCORRECT generator to show the problem
genFinIncorrect : (n : Nat) -> Gen1 (Fin n)
genFinIncorrect (S k) = FS <$> genFinIncorrect k
genFinIncorrect Z = ?wat -- What could we possibly write here?
We have a problem. In the (S k) case, we can walk all possible values of Fin (S k) and convert them to Fin to create a generator. But in the Z
case, what can we do?
The type is Gen1 (Fin 0), but Fin 0 has no values. We can’t use pure because we don’t have a value to give it. We’re stuck.
Note
The Gen0 emptiness flag indicates this generator might fail to produce a value. Use it for types that may not have inhabitants (like Fin 0).
This is the problem DepTyCheck is designed to solve. We need a way to tell the system that a generator is intentionally empty.
Step 2: Introducing Gen0 and empty
DepTyCheck provides two new tools to solve this exact problem:
Gen0 a: A type for a generator that might produce a value of typea. Think of it as a “possibly empty” recipe.empty: A specific generator of typeGen0 athat is guaranteed to produce nothing. It’s the recipe for an empty set.
Let’s use these to fix our incorrect generator.
Add the correct genFin generator to your EmptyTutorial.idr file
-- A correct, safe generator for Fin n
genFin : (n : Nat) -> Gen0 (Fin n)
genFin Z = empty
genFin (S k) = FS <$> elements' (allFins k)
The changes are small but critical:
The return type is now
Gen0 (Fin n), which signals that the result may be empty.
Note
The empty generator fails immediately. Combined with pick, it lets you express “try A, and if it fails, try B” logic without explicit branching.
In the
Zcase, we can now simply returnempty. This correctly tellsDepTyCheckthat the recipe forFin 0produces nothing.
Step 3: Running a Gen0 Generator
Because a Gen0 generator might not produce a value, we can’t use pick1 (which promises to return one value). Instead, we must use pick, which
safely handles the possibility of emptiness.
pick1 genreturnsapick genreturnsMaybe a
Let’s see this in action.
Run genFin for both an inhabited case (Fin 3) and an empty case (Fin 0)
run : IO ()
run = do
printLn !(pick (genFin 3))
printLn !(pick (genFin 0))
Run it
echo -e ':exec run' | rlwrap pack repl ./src/CustomGen.idr
You will see something like that:
Just 2
Nothing
First result will be a Fin 3 value wrapped in a Just, because a value could be generated. But for the second we used empty in our definition for
genFin Z, DepTyCheck knows this generator can’t produce a value, and pick safely returns Nothing
This is the core of safe, dependently-typed testing. The type system allows us to model that some generations are impossible, and the runner (pick)
allows us to handle those cases gracefully at runtime without any crashes.
Step 4: Filtering with suchThat
A type can also be effectively “empty” if we filter its values so much that none remain. DepTyCheck provides suchThat for this.
I.e. g suchThat p takes a generator g and a predicate (a function that returns a Bool) p. It runs the generator g, and if the value it
produces satisfies p, it returns it. If not, the generation fails for that attempt.
Because the condition might never be met, suchThat always returns a Gen0.
Add these two generators to your EmptyTutorial.idr file
isEven : Nat -> Bool
isEven n = n `mod` 2 == 0
-- A generator that tries to find an even number between 0 and 10.
-- This will sometimes succeed and sometimes fail (if `choose` picks an odd number).
genEven : Gen0 Nat
genEven = choose (0, 10) `suchThat` isEven
-- A generator that tries to find a number greater than 10 in a range where none exist.
-- This recipe is impossible and will always be empty.
genImpossible : Gen0 Int
genImpossible = choose (1, 10) `suchThat` (> 10)
Run them both
runSuchThat : IO ()
runSuchThat = do
printLn !(pick genEven)
printLn !(pick genImpossible)
Run it
echo -e ':exec runSuchThat' | rlwrap pack repl ./src/CustomGen.idr
You will see something like that:
Just 2
Nothing
This demonstrates another critical aspect of Gen0: it allows for speculative generation that might fail, giving you a powerful way to define complex
properties.
Next Steps
Now that you’ve mastered manual generation for both simple and complex types, it’s time to see how DepTyCheck can do this work for you automatically.
Next Tutorial: Continue to Measuring Your Test Coverage to learn how to analyze the quality of your generated data.