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.

Short Motivation

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
open FsSpec
type InventoryCount = private InventoryCount of int
module InventoryCount = 
    let spec = Spec.all [Spec.min 0; Spec.max 1000]
    let tryCreate n =
      Spec.validate spec n 
      |> Result.map InventoryCount

// Generate data
let inventoryAmounts = Gen.fromSpec InventoryCount.spec |> Gen.sample 0 10

Longer Motivation

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

1
2
3
4
5
6
type PhoneNumber = private PhoneNumber of string
module PhoneNumber = 
    let tryCreate str =
      if (Regex(@"\d{3}-\d{4}-\d{4}").IsMatch(str))
      then Some (PhoneNumber str)
      else None 

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let inventorySpec = Spec.all [Spec.min 0; Spec.max 1000]

// Validation
Spec.isValid inventorySpec 20

// Explanation: understand what constraints failed (as a data structure)
Spec.explain inventorySpec -1

// Validation Messages
Spec.explain inventorySpec -1 |> Formatters.prefix_allresults // returns: "-1 failed with: and [min 0 (FAIL); max 1000 (OK)]"

// Data Generation (with FsCheck)
Gen.fromSpec inventorySpec |> Gen.sample 0 10  // returns 10 values between 0 and 1000

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

1
2
3
let markdown = //could vary in complexity
let sanitizedMarkdown = markdown &&& //whatever sanitization looks like
let recipeIngredientSpec = sanitizedMarkdown &&& notEmpty 

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 FullName, FutureDate, PastDate, NonNegativeInt etc. 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.

Limitations

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.

Consider

1
let spec = Spec.predicate "predicate min/max" (fun i -> 0 < i && i < 5)

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

FsSpec is latest step in a long journey to better leverage value constraints in code. This has led me through testing approaches (and their pitfalls), dependent type systems, and math proofs.

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.

Conclusion

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.