Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonOrder Totals Tutorial
This tutorial builds an order from untrusted input and then calculates over it. The point is what happens after construction: every invariant admitted at the boundary removes a branch, an option, or a guard from the code downstream.
open System
open Reified
open Reified.Refinements
open Reified.Result
open Reified.ResultDSL
SystemReifiedRefinementsResultReified.ResultDSLThe concise result vocabulary: the result { } computation expression, its accumulating result.list { } / result.array { } variants, and the lightweight admission functions (okIf, failIf, require, orError, mapError). Optional and opt-in, in the same shape as Reified.DataDSL, Reified.ConstraintDSL, and Reified.SchemaDSL: open Reified.Result for Result, then open Reified.ResultDSL for this vocabulary. Deliberately small: generic combinators such as map, bind, orElse, tap, and the traversal helpers stay qualified as Result.map, Result.bind, and so on.
Model the domain
type OrderLine =
{ Sku: NonBlankString
Quantity: int
UnitPrice: decimal }
type Order =
{ Reference: NonBlankString
Lines: NonEmptyList<OrderLine>
Discount: UnitInterval
Delivery: Interval<DateTimeOffset> }
06-refined_60-tutorials_10-order-totals.md_page.OrderLineSku: NonBlankStringReified.Refinements.NonBlankStringA string that is not null, empty, or whitespace.
Quantity: intintAn abbreviation for the CLI type . Basic Types
UnitPrice: decimaldecimalAn abbreviation for the CLI type . Basic Types
06-refined_60-tutorials_10-order-totals.md_page.OrderReference: NonBlankStringLines: NonEmptyList<OrderLine>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.
Discount: UnitIntervalReified.Refinements.UnitIntervalA finite double between zero and one inclusive. The only type in this package closed under multiplication: a product of two values in [0, 1] is always in [0, 1], with no overflow to guard against. It is not closed under addition — 0.7 + 0.7 leaves the range — so add is deliberately absent in favour of saturatingAdd and complement.
Delivery: Interval<DateTimeOffset>Reified.Refinements.Interval`1An inclusive range of ordered values where Lower <= Upper. An interval is always inhabited. Emptiness is represented by Interval option, which is what intersect returns, rather than by a second type — carrying a possibly-empty interval would double every operation without making any of them total. The two ends are named for their roles as bounds, not for a traversal: an interval has no direction, and between 5 1 equals between 1 5. Wire formats that read better as start/end choose those field names at the schema, which is independent of these members — see RefinedSchemas.dateRange. The invariant assumes the value type is totally ordered. float and float32 are not: NaN compares false against everything, so between nan x cannot order its arguments and yields an interval whose bounds are inverted. Use Interval<FiniteFloat>, which excludes NaN by construction, or create, which rejects the pair. This is the same defect exists to remove.
System.DateTimeOffsetRepresents a point in time, typically expressed as a date and time of day, relative to Coordinated Universal Time (UTC).
Read the field types as a specification. An order has at least one line. The discount is a
proportion, so it cannot be 140% or NaN. The delivery window's start is not after its
end. None of those facts needs restating later, because none of them can be false.
Quantity and price are ordinary numbers, checked on the way in. They are not refined types because F# cannot carry "greater than zero" through arithmetic — see why there are no refined numbers.
Admit the input
let orderLine rawSku rawQuantity rawPrice =
result {
let! sku = Refine.nonBlankString rawSku
let! _ = Constraint.greaterThan 0 rawQuantity
let! _ = Constraint.greaterThan 0m rawPrice
return { Sku = sku; Quantity = rawQuantity; UnitPrice = rawPrice }
}Invalid values are rejected here and nowhere else:
orderLine "SKU-1" 0 9.99m // Error [ OutOfRange (GreaterThan "0", Some "0") ]
orderLine " " 1 9.99m // Error [ Blank ]
Refine.nonEmptyList ([]: OrderLine list)
// Error [ InvalidLength (MinimumLength 1, Some 0) ]
UnitInterval.create 1.4 // Error [ OutOfRange (Between ("0", "1"), Some "1.4") ]
UnitInterval.create Double.NaN // Error — NaN is outside every intervalTwo of the four fields have a total constructor, which is the one to prefer when the input has an obvious correct reading:
let window = Interval.between requestedFrom requestedTo // cannot fail: orders the pair
let discount = UnitInterval.clamp rawDiscount // cannot fail: clamps into [0, 1]Interval.between accepts the two instants in either order. Use Interval.create instead
when an inverted pair means the caller made a mistake you would rather report than repair.
Calculate, without re-checking anything
Line and order totals
The numbers are plain, so the arithmetic is plain:
let lineTotal (line: OrderLine) = decimal line.Quantity * line.UnitPrice
let subtotal (order: Order) =
order.Lines |> NonEmptyList.map lineTotal |> NonEmptyList.reduce (+)reduce needs no seed and no empty case — that is the invariant paying, and it pays
without putting a Result between every operation.
Discount
let payable (order: Order) =
let multiplier = UnitInterval.complement order.Discount
subtotal order * decimal (UnitInterval.value multiplier)complement is total and closed, so multiplier is guaranteed to be in [0, 1]. That is
what makes the result safe without a check: the payable amount cannot exceed the subtotal
and cannot go negative, because there is no discount value that would allow it.
The conversion to decimal is deliberate rather than hidden. UnitInterval is a double,
money is a decimal, and mixing the two is a rounding decision that belongs in your code.
Statistics
let largestLine (order: Order) =
order.Lines |> NonEmptyList.maxBy (fun line -> line.Quantity)
let lineCount (order: Order) =
NonEmptyList.length order.Lines
let averageUnitPrice (order: Order) =
order.Lines |> NonEmptyList.averageBy (fun line -> line.UnitPrice)
largestLine: Order -> OrderLineorder: Order06-refined_60-tutorials_10-order-totals.md_page.OrderLines: NonEmptyList<OrderLine>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Reified.Refinements.NonEmptyListModuleOperations over lists that carry their non-emptiness in the type.
maxBy: ('value -> 'a) -> NonEmptyList<'value> -> 'valueReturns the item with the largest projected key. Total.
line: OrderLineQuantity: intlineCount: Order -> intlength: NonEmptyList<'value> -> intReturns the number of items as a plain int, matching List.length.
averageUnitPrice: Order -> decimalaverageBy: ('value -> ^a) -> NonEmptyList<'value> -> ^aAverages a projection of the items. Total, for the same reason as average.
UnitPrice: decimalmaxBy returns an OrderLine, not an option. averageBy returns a decimal, not an
option, because the divisor is the length and the length is at least one. Each of those is
a branch the plain-list version would have had to write:
// what the same three functions cost over an ordinary list
let largestLine lines = lines |> List.sortByDescending (fun l -> l.Quantity) |> List.tryHead
let averageUnitPrice lines =
if List.isEmpty lines then None
else Some (List.sumBy _.UnitPrice lines / decimal (List.length lines))The refined side is the shorter of the two, which is the point: the collection modules
carry the everyday operations (sum, sumBy, average, choose, countBy) as well as
the total ones, so nothing here converts back to a list to do ordinary work.
Delivery window
let isDeliverable (order: Order) (candidate: DateTimeOffset) =
Interval.contains candidate order.Delivery
let overlapWith (order: Order) (other: Interval<DateTimeOffset>) =
Interval.intersect order.Delivery other // Interval option — None when disjointintersect returns an option because two windows may not overlap. That is the honest
shape: an empty interval is not representable, so emptiness is reported rather than
smuggled into a value whose Lower is somehow above its Upper.
Catch a duplicate the type system can see
Distinctness is a relationship between values, so it needs a checked constructor — but the resulting type then converts to a map without silently dropping entries:
let skus (order: Order) =
order.Lines
|> NonEmptyList.map (fun line -> NonBlankString.value line.Sku)
|> NonEmptyList.toList
|> DistinctList.create // Error [ Duplicate ] when the same SKU appears twice
let lineBySku (order: Order) =
order.Lines
|> NonEmptyList.toList
|> List.map (fun line -> NonBlankString.value line.Sku, line)
|> DistinctList.create
|> Result.bind DistinctList.toMap
skus: Order -> Result<DistinctList<string>,Violation>order: Order06-refined_60-tutorials_10-order-totals.md_page.OrderLines: NonEmptyList<OrderLine>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Reified.Refinements.NonEmptyListModuleOperations over lists that carry their non-emptiness in the type.
map: ('value -> 'a) -> NonEmptyList<'value> -> NonEmptyList<'a>Applies a mapping to every item. Non-emptiness is preserved.
line: OrderLineReified.Refinements.NonBlankStringModuleOperations over text known to carry non-whitespace content.
value: NonBlankString -> stringReturns the underlying string value.
Sku: NonBlankStringtoList: NonEmptyList<'value> -> 'value listReturns the refined value as a standard list.
Reified.Refinements.DistinctListModuleOperations over lists known to hold no duplicates.
create: 'a seq -> Result<DistinctList<'a>,Violation>Admits a list, failing when it holds duplicates.
lineBySku: Order -> Result<Map<string,OrderLine>,Violation>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.
map: ('T -> 'U) -> 'T list -> 'U listBuilds a new collection whose elements are the results of applying the given function to each of the elements of the collection. The function to transform elements from the input list. The input list. The list of transformed elements. let inputs = [ "a"; "bbb"; "cc" ] inputs |> List.map (fun x -> x.Length) Evaluates to [ 1; 3; 2 ]
Reified.Result.ResultModuleFail-fast helpers over the standard F# Result type.
bind: ('a -> Result<'b,'c>) -> Result<'a,'c> -> Result<'b,'c>Binds a result to the next fail-fast operation.
toMap: DistinctList<'key * 'value> -> Result<Map<'key,'value>,Violation>Builds a map from a distinct list of pairs, failing when two pairs share a key. Distinctness holds over whole pairs, not over keys: [ 1, "a"; 1, "b" ] is a legitimate DistinctList whose entries would collide in a map. The check is what makes the conversion lossless — Map.ofList would silently keep one. For the unconditional guarantee use toSet, where distinct elements always produce a set of the same size.
Map.ofList on an ordinary list keeps only the last of each duplicate key and reports
nothing. DistinctList.toMap returns a Result instead: distinctness holds over whole
pairs rather than over keys, so it checks the keys and tells you about a collision rather
than losing an entry.
What the invariants removed
| Fact carried by a type | Branch it removed |
|---|---|
NonEmptyList has a first item |
no tryHead, no option from max/reduce |
NonEmptyList has a positive length |
no divide-by-zero on an average |
UnitInterval is in [0, 1] |
no clamping the multiplier before applying it |
Interval has Lower <= Upper |
no "did they send these backwards" check |
DistinctList has no duplicates |
no silent collapse building a set; a reported failure building a map |
None of these is a claim about construction. Each is a claim about every line of code downstream.
Next
- Built-in Refined Values — what each type is closed under.
- Customer Id — define a refined type of your own, and give it a schema.
- Compose Parse and Refinement — mapping failures to application errors.