Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonRefined
Reified.Refinements supplies types that carry an invariant, together with the operations that
invariant makes possible. Guarded construction is how a value is admitted, but it is not
the reason to reach for a refined type — a wrapper that only checks on the way in leaves
callers unwrapping it at first use, and the invariant buys nothing after the boundary.
The types here are chosen so that later code can be simpler:
let total = NonEmptyList.reduce (+) lines // no seed, no empty case, no option
let largest = NonEmptyList.max lines // total
let ratio = UnitInterval.multiply a b // closed: still in [0, 1]NonEmptyList.max returns a value rather than an option because the type makes the empty
case unrepresentable. That is the test a type has to pass to belong here: it should make a
partial operation total, guarantee a property later operations rely on, encode a
relationship between values, preserve an invariant across a useful family of operations,
or remove a branch from consumers rather than only from construction.
Validation that fails that test belongs in a constraint instead. Trimmed text and slugs make
nothing later total or simpler: no operation on a string needs the ends to be free of
whitespace, and none needs a particular pattern. So they are Constraint.trimmed and
Constraint.pattern on an ordinary string, not types. See
When not to make a type.
Reified.Refinements depends only on Reified.Constraint. It does not
parse text and does not normalize input.
dotnet add package Reified.Refinements
open Reified
open Reified.Refinements
ReifiedRefinementsAdmission
Every built-in type has a constructor returning Result<'refined, Violation>:
let name : Result<NonBlankString, Violation> = Refine.nonBlankString "Ada"
let lines : Result<NonEmptyList<string>, Violation> = Refine.nonEmptyList [ "a"; "b" ]
Refine.nonBlankString " " // Error [ Blank ]
Refine.nonEmptyList [] // Error [ InvalidLength (MinimumLength 1, Some 0) ]
name: Result<NonBlankString,Violation>Microsoft.FSharp.Core.FSharpResult`2Helper type for error handling without exceptions. Choices and Results
Reified.Refinements.NonBlankStringA string that is not null, empty, or whitespace.
Reified.ViolationWhy a value failed its constraint. A diagnostic contract, not an application error union. Domain code maps a whole violation once with Result.mapError; Schema adds the path at which it occurred. Violations are plain comparable data. No closure and no constraint description is reachable from one, so structural equality holds and a violation can be retained and compared long after the constraint that produced it went out of scope. There is no promised wire format. Reified-produced groups are never empty and never unary: a single failing child is returned directly rather than wrapped. The first * rest shape encodes non-emptiness only; non-unarity is a normalization invariant.
Reified.Refinements.RefineModuleSmart constructors for built-in refined values, and the refinements behind them. Text, Character, Collection, and Choice are nested here rather than declared beside Refine. Each names a concept general enough to shadow something a caller already has in scope, and none of them is a companion to a type this package exports, so there is nothing to gain from the type-and-module pairing that keeps NonBlankString and DistinctList at the top level.
nonBlankString: string -> Result<NonBlankString,Violation>lines: Result<NonEmptyList<string>,Violation>Reified.Refinements.NonEmptyList`1A list that contains at least one item. The case is public: non-emptiness is carried by the representation rather than by a checked constructor, so head, last, reduce, min, and max are total and pattern matching is available to callers.
stringAn abbreviation for the CLI type . Basic Types
nonEmptyList: 'a seq -> Result<NonEmptyList<'a>,Violation>Some types also offer a total constructor, which is the one to prefer when the input has an obvious correct reading:
let lines = NonEmpty(first, rest) // cannot fail
let window = Interval.between finish start // cannot fail: orders the pair
let ratio = UnitInterval.clamp 1.5 // cannot fail: clamps to 1.0Read the underlying value through the Value member or the module's value function.
Where the invariant pays
Start with the smallest version of the contrast — one partial operation becoming total:
List.max lines // throws on an empty list
NonEmptyList.max lines // total: returns the valueAvoiding the exception means writing the option version by hand, and then every caller unwraps it:
let tryMax lines =
if List.isEmpty lines then None else Some (List.max lines)
tryMax: 'a list -> 'a optionlines: 'a listMicrosoft.FSharp.Collections.ListModuleContains operations for working with values of type . Operations for collections such as lists, arrays, sets, maps and sequences. See also F# Collection Types in the F# Language Guide.
isEmpty: 'T list -> boolReturns true if the list contains no elements, false otherwise. The input list. True if the list is empty. [ ] |> List.isEmpty Evaluates to true [ "pear"; "banana" ] |> List.isEmpty Evaluates to false
NoneThe representation of "No value"
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
max: 'T list -> 'TReturn the greatest of all elements of the list, compared via Operators.max. Raises if list is empty The input list. Thrown when the list is empty. The maximum element. let inputs = [ 10; 12; 11 ] inputs |> List.max Evaluates to 12 let inputs = [ ] inputs |> List.max Throws System.ArgumentException.
That option is the empty case travelling downstream. NonEmptyList settles it once, at
construction, and every later caller reads a value.
The same saving shows up in aggregates:
// plain: every caller re-establishes what is already true
let averageLine (lines: OrderLine list) =
if List.isEmpty lines then None
else Some (List.sumBy _.Total lines / decimal lines.Length)
// refined: the empty case cannot arise, so there is nothing to return an option for
let averageLine (lines: NonEmptyList<OrderLine>) =
NonEmptyList.averageBy _.Total linesThe refined version is shorter, not just safer. That is deliberate: the collection types
carry the ordinary list vocabulary — sum, sumBy, average, choose, countBy,
item — as well as the operations the invariant makes total. A refined type that only
offered the clever operations would push you back through toList for everyday work, and
the invariant would be lost halfway down the pipeline. See
the catalogue.
Order Totals works this through on a realistic domain.
There are no refined numbers
F# cannot propagate an invariant through arithmetic the way a refinement-typed language
can, so a PositiveInt would have to re-establish "greater than zero" at every step. With
unchecked integer arithmetic — Int32.MaxValue + 1 is negative — that means returning
Result from addition, and a Result per arithmetic step is bulk that hides mistakes
rather than catching them.
Numeric ranges are constraints instead:
field _.Quantity { constrain (Constraint.greaterThan 0) }FiniteFloat is the exception that proves the rule: it is worth having because NaN and
infinity silently destroy an aggregate — List.average [ 12.5; 3.0; nan; 8.25 ] is
NaN — not because of arithmetic or ordering. See
the catalogue.
Define your own
The machinery is public, so an application type gets the same treatment:
type CustomerId =
private
| CustomerId of int
member this.Value =
let (CustomerId value) = this
value
module CustomerId =
let refinement = Refinement.define (Constraint.greaterThan 0) CustomerId _.Value
let create value = Refinement.create refinement value
06-refined__index.md_page.CustomerIdCustomerIdintAn abbreviation for the CLI type . Basic Types
this: CustomerIdValue: CustomerId -> unit -> intvalue: int06-refined__index.md_page.CustomerIdModulerefinement: Refinement<int,CustomerId>Reified.Refinements.RefinementCreates and applies reusable refinement definitions.
define: Constraint<'underlying> -> ('underlying -> 'refined) -> ('refined -> 'underlying) -> Refinement<'underlying,'refined>Defines a refinement from one constraint, a constructor, and the reverse projection. Compose several rules with Constraint.all before defining, and reach for Constraint.custom when the rule is an arbitrary predicate. Both produce an ordinary constraint, so there is no separate plural or check-taking constructor. type RetryCount = RetryCount of int let retryCount = Refinement.define (Constraint.between 0 10) RetryCount (fun (RetryCount value) -> value)
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
greaterThan: 'value -> Constraint<'value>Requires a value strictly greater than the supplied bound. let quantity : Constraint<int> = Constraint.greaterThan 0
_arg1: CustomerIdValue: intcreate: int -> Result<CustomerId,Violation>create: Refinement<'underlying,'refined> -> 'underlying -> Result<'refined,Violation>Constructs a refined value, reporting why the raw value was not admitted. type RetryCount = RetryCount of int let retryCount = Refinement.define (Constraint.between 0 10) RetryCount (fun (RetryCount value) -> value) 3 |> Refinement.create retryCount |> Result.mapError Violation.render
Refinement.constraints exposes the same rules to Schema and other interpreters, so the
type describes itself at a boundary without restating the rule.
Customer Id builds one end to end, including its schema.
Once the type exists, a schema field of that type needs nothing but the field: field _.Id
resolves the refinement, and a failure at that field is reported with its path alongside every
other field's. See Schema integration.
Read next
- Order Totals uses the built-in types in anger.
- Built-in Refined Values lists what each type buys you.
- Customer Id defines a refined type of your own.
- Define Refined Types is the reference for
Refinement. - Schema Integration applies refinements at structured boundaries.
- Compose Parse and Refinement maps failures into application errors.