Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonBuilt-in Refined Values
A refined type earns its place by what it lets you stop writing. Each type below makes some partial operation total, guarantees a property later operations rely on, or removes a branch from every consumer. A wrapper that only validates at construction is a constraint, not a type — see When not to make a type.
open Reified
open Reified.Refinements
ReifiedRefinementsWhat each type buys you
| Type | Closed under | Made total |
|---|---|---|
NonEmptyList<'T>, NonEmptyArray<'T> |
map, append, rev, sort, distinct, scan, truncate |
head, last, reduce, min, max, average, item |
Interval<'T> |
between, span, clamp, mapMonotonic |
contains, clamp |
Bounded<'T> |
clamp, map (re-clamps) |
clamp |
NonBlankString |
append, trim, toUpper, toLower |
split |
UnitInterval |
*, complement, lerp, min, max |
clamp |
FiniteFloat, FiniteFloat32 |
negate, abs |
aggregates that cannot be silently poisoned |
DistinctList<'T> |
add, remove, union, intersect, sort, filter, map |
toSet |
Collections
NonEmptyList carries its non-emptiness in the representation, so the case is public and
you can pattern match on it:
let lines = NonEmpty(firstLine, remainingLines) // total, no Result
let (NonEmpty(first, rest)) = lines // total
let total = NonEmptyList.reduce (+) lines // total, needs no seed
let largest = NonEmptyList.max lines // total, no optionNonEmptyList.create admits an ordinary sequence and returns
Result<NonEmptyList<'T>, Violation>; NonEmptyList.ofList returns an option.
Filtering can remove every item, so filter returns an ordinary list and tryFilter
returns an option. traverseResult applies a fallible mapping across the list and
accumulates every failure rather than stopping at the first.
NonEmptyArray stays smart-constructed rather than structural. A head-and-tail
representation would forfeit contiguous storage and indexed access, which are the reasons
to choose an array; the total head/last/reduce/max still apply.
DistinctList exists for toSet: distinct items always produce a set of the same size,
where Set.ofList on an ordinary list silently collapses duplicates.
toMap and toMapBy return a Result, because distinctness holds over whole items
rather than over keys — [ 1, "a"; 1, "b" ] is a legitimate DistinctList whose entries
would collide. They report the collision instead of dropping an entry the way Map.ofList
does.
Everyday operations
A refined collection is still a collection. Alongside the operations the invariant makes
total, each module carries the everyday list vocabulary, so a pipeline never has to drop
back to List and re-admit the result:
let total = NonEmptyList.sumBy _.Total lines // not List.sumBy on a converted list
let mean = NonEmptyList.average prices // total: the divisor is never zero
let biggest = NonEmptyList.maxBy _.Quantity lines // total: no optionThe two tiers are worth telling apart, because only the second is a reason to reach for the type:
| Tier | Operations | What the invariant does |
|---|---|---|
| Parity | sum, sumBy, choose, countBy, tryPick, tryFind, tryFindBack, tryFindIndex, pairwise, iter, iteri, fold, exists, forall |
nothing — these exist so the type is usable without a round trip |
| Earned | average, averageBy, item, truncate, skip, map2, init, replicate, scan, chunkBySize |
removes the partiality: no raise, no option, no empty case |
average is the clearest of the earned ones: List.average [] raises, while the divisor
here is the length, which is at least one. item, truncate, init, and chunkBySize
clamp their count into range instead of raising or emptying the list, so non-emptiness
survives; skip and map2 are total where List.skip and List.map2 raise. Operations
that can genuinely destroy the invariant stay honest: filter, choose, and skip
return an ordinary list, and tryFilter/tryChoose return an option.
scan is non-empty by construction rather than by inheritance — a scan always emits its
seed.
NonEmptyArray mirrors the whole set, and DistinctList carries the operations that
cannot introduce a duplicate (sort, sortBy, rev, truncate, filter, fold,
sum, sumBy); map and choose deduplicate, because neither a mapping nor a chooser
need be injective.
Intervals and bounds
One generic Interval<'T> covers any ordered value. It is always inhabited, so emptiness
is reported as an option rather than by a second type:
let window = Interval.between start finish // total: orders its arguments
let overlap = Interval.intersect window other // Interval option — honest about emptiness
let clamped = Interval.clamp candidate window // totalThe ends are Lower and Upper: they name the two bounds' roles, not a traversal. An
interval has no direction, so between 5 1 equals between 1 5.
That is why there are two constructors. between accepts either order and repairs it;
create asserts the pair is already ordered and fails when it is not. Reach for between
in code, and create at a boundary, where an inverted pair is a caller error worth
reporting rather than silently swapping.
union returns None when the two intervals do not overlap, because joining them would
invent a gap; span closes the gap deliberately.
For instants, DateRange abbreviates Interval<DateTimeOffset> and
RefinedSchemas.dateRange uses start/end on the wire. That is a schema-level naming
choice, not a second type — every Interval operation applies unchanged.
Bounded<'T> pairs a value with the interval it must stay inside. Bounds are carried at
run time, so Bounded.clamp is total and Bounded.map re-clamps — a mapping cannot break
the invariant.
Why there are no refined numbers
There is no PositiveInt, NonNegativeDecimal, or NonZeroInt here, and that is
deliberate.
F# cannot propagate an invariant through arithmetic. A language with refinement types
infers that a + b is positive when a and b are; F# cannot, so every step has to
re-establish the fact by hand. Since integer arithmetic is unchecked —
Int32.MaxValue + 1 is negative — an addition returning PositiveInt would be unsound,
which leaves returning Result:
// what a refined numeric type costs for ((2 + 3) * 4) + 1
PositiveInt.add a b
|> Result.bind (fun s -> PositiveInt.multiply s c)
|> Result.bind (fun m -> PositiveInt.add m d)Nobody writes that. They unwrap, compute, and re-admit — so the type adds bulk at every use site and buys nothing in return, which is more likely to hide an arithmetic mistake than to catch one.
Numeric ranges are therefore constraints:
field _.Quantity { constrain (Constraint.greaterThan 0) }If you want a nominal type for a numeric identifier — where the point is identity rather
than arithmetic — define one over the same constraint. Refinement is public, and
Customer Id works it through.
Floating point
FiniteFloat excludes NaN and the infinities. Its value is that aggregation means
something: one bad reading destroys a whole aggregate, silently.
List.sum [ 12.5; 3.0; nan; 8.25 ] // NaN
List.average [ 12.5; 3.0; nan; 8.25 ] // NaN
Microsoft.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.
sum: ^T list -> ^TReturns the sum of the elements in the list. The input list. The resulting sum. let input = [ 1; 5; 3; 2 ] input |> List.sum Evaluates to 11.
nan: floatEquivalent to
average: ^T list -> ^TReturns the average of the values in a non-empty list. The input list. Thrown when the input list is empty. The resulting average. [1.0 .. 9.0] |> List.average Evaluates to 5.0
No exception, no obviously wrong number — just a dashboard that reads NaN some time
later. Admitting through FiniteFloat localises that to the one bad reading at the
boundary. Infinity poisons sum and average identically, which is why the type excludes
both rather than only NaN.
NaN also makes List.contains and List.distinct wrong, since both use IEEE equality
under which NaN is not equal to itself.
FiniteFloat.negate value // closed
FiniteFloat.average values // one Result at the end, not one per stepIt is not needed for sorting or for Map, Set and Dictionary keys. F# generic
comparison already orders NaN consistently — compare nan nan is 0, and NaN sorts
first — so those work on plain float. What stays broken is a comparison hand-written
with < and >: it reports NaN equal to every value, which is intransitive and makes
sortWith return unsorted output without raising.
For the same reason there are no refined numbers, it offers no pairwise arithmetic:
unwrap with value, compute in plain float, and re-admit once.
UnitInterval holds a proportion in [0, 1]. It is the only type here closed under
multiplication, which is the reason to reach for it:
UnitInterval.multiply a b // total and closed
UnitInterval.complement a // total
UnitInterval.lerp low high a // total, always lands between the endpoints
UnitInterval.inverseLerp low high v // the inverse: where v sits, clamped
UnitInterval.saturatingAdd a b // not closed under +, so this clampscomplement is an involution only up to floating-point rounding — exact for dyadic values,
approximate otherwise.
FiniteFloat32 carries the same guarantee for single precision. It has no canonical wire
schema, because JSON has no single-precision number — widen with toFiniteFloat at a
boundary.
Bounded<'T> gets its schema from RefinedSchemas.bounded bounds itemSchema: the bounds
belong to the field rather than to each value, so they are supplied once.
Text
NonBlankString preserves accepted text exactly, and its operations preserve inhabitation:
NonBlankString.append first second // total
NonBlankString.trim value // total — trimming inhabited text leaves it inhabited
NonBlankString.split "," value // NonEmptyList<NonBlankString>, never emptyWhen not to make a type
Trimmed text, slugs, email addresses, and length bounds carry no invariant that any later operation uses. Nothing about a string becomes total or loses a branch once you know its ends are free of whitespace, so a wrapper would only be unwrapped at first use. That is the test, not closure: trimmedness happens to survive concatenation, and it still earns no type. Slug does not even get that far — joining two slugs can break the pattern.
Express them as constraints on a primitive instead — the metadata reaching interpreters is identical:
field _.DisplayName { constrain Constraint.trimmed }
field _.Slug {
constrain Constraint.present
constrain (Constraint.pattern slugPattern)
}The field's schema is inferred from its type, so a constraint needs no withSchema, and
each constraint can sit on its own line.
If you do want a nominal type in your own domain, the machinery is still here — see Define Refined Types.
Schema resolution
Every type above has a canonical wire schema, so a bare field resolves it with no
withSchema. The 64-bit and floating-point types sit on the Schema.int64 and
Schema.float primitives rather than being mapped onto decimal, which would change
their meaning. Note that JSON has no literal for NaN or the infinities: a schema that
must reject them should use FiniteFloat, whose finite constraint is inspectable
metadata like any other.
Continue with Compose Parse and Refinement and Define Refined Types.