Type-driven and Domain-driven approaches commonly represent constrained values with a type. This approach is good, but generally leaves the constraints buried in functions. FsSpec exposes those constraints as data so they can be programmatically understood for validation, data generation, and more.
FsSpec represents value constraints as data to reuse one constraint declaration for validation, data generation, error explanation, and more.
It also makes for a concise and consistent Type-Driven approach
Type-Driven and/or Domain-Driven systems commonly model data types with constraints. For example,
- a string that represents an email or phone number (must match format)
- an inventory amount (perhaps an int between 0 and 1000)
- Birthdates (can’t be in the future)
We centralize these constraints by wrapping them in a type, such as
This is great. It prevents defensive programming from leaking around the system and clearly encodes expectations on data. It avoids the downsides of primitive obsession.
However, we’re missing out on some power. We’re encoding constraints in a way that only gives us pass/fail validation. We have to duplicate constraint information if we want to explain a failed value, generate data, or similar actions.
FsSpec represents these constraints as data so that our programs can understand the constraints on a value.
There are also other possibilities FsSpec doesn’t have built-in. For example,
- Comparing specifications (i.e. is one a more constrained version of the other)
- Serialize and interpret constraints for use in different UI technologies
- Convention-based generator registration with property testing libraries (e.g. with FsCheck)
Readability, Composition, and Reuse
Specs are just data. They can be centralized, reused, and composed like any other data. This opens up opportunity for readable and reusable data constraints.
For example, we can break up complex constraints
Specs don’t have to be associated with a type, making them light weight to break up or compose into new specs.
Breaking out sub-constraints improves readability, but also identifies constraints we might reuse, like
markdown or maybe
Such cross-cutting constraints could be collected into a module for centralized reuse, or even shipped in a nuget package.
Specs can be composed any way we compose other data: mapped in a dictionary, or collected in a list, even persisted and loaded.
FsSpec interprets constraint data for many purposes, but not all data is created equal.
In particular, custom constraints are added to FsSpec as predicates. These are very flexible for validation, but their meaning cannot be always be inferred. This means FsSpec cannot intelligently generate data from custom constraints. The best it can do is filter unconstrained data based on the predicate.
The above case will probably not generate. It is unlikely many random integers will fall in the narrow range of 0 to 5, so the generator keeps filtering samples until it times out. Users can wrap core operations (generate, validate, explain) with functions that handle their custom spec predicates if needed.
Original Experiments and Language-integrated constraints
Originally, I tried to extend F#’s type system with constrained values. However, F# intentionally does not include macros or other language extension mechanisms.
It also turns out that adding constraints to the type system has already been done, and much more thoroughly. Languages like F*, Idris, or Dafny use constraints on types to prove qualities of a program at compile-time. For example, F* can prove that a program handles all it’s input values and the general bounds of resource usage. This class of languages is called a dependent-type system, or a proof-oriented language.
Learning about these languages pivoted my approach. FsSpec instead leans into the static type system and empowers patterns already used in Type- and Domain-driven systems. It allows the existing type system to enforce expectations (i.e. via constrained construction) while the library focuses on making the constraint data accessible and consumable. Users can opt into F* if they need the full power and complexity of dependent types.
FsSpec exposes value constraints as data. This allows programs to operate on the constraints themselves and resuse a single declaration for many purposes. I have high hopes this approach can normalize and simplify type-driven development, ultimately leading to clearer and safer systems.