Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result

Customer Id Tutorial

The built-in types cover common shapes. This tutorial defines a domain-specific type and tests whether it warrants a wrapper.

Verified
open Reified
open Reified.Refinements

Decide whether it earns a type

A refined type is worth defining when the invariant does something for the code that receives it. Ask what becomes total, or what branch disappears:

Candidate Verdict
CustomerId, a positive account number Type. Lookup, ordering, and equality all rely on it, and an id of 0 is a bug you want to catch once.
EmailAddress, matches an email pattern Constraint. Nothing downstream is total because of it; you unwrap it to send mail.
ShippingWeight, positive, and summed across a parcel Constraint. The moment you add two of them, F# cannot carry "positive" through, so the type turns every sum into a Result.
NormalisedName, trimmed and lower-cased Neither. That is a transformation, so it belongs in Parse.

Only the first changes what later code can assume. The second is real validation with no downstream consequence; the third is validation that arithmetic immediately undoes. Both stay constraints on the underlying value:

Fragment
field _.Email {
    constrain Constraint.present
    constrain Constraint.email
}

Define the type

A refined type is a private wrapper, one canonical projection, and a refinement:

Verified
type CustomerId =
    private
    | CustomerId of int

    /// The canonical underlying representation.
    member this.Value =
        let (CustomerId value) = this
        value

    override this.ToString() =
        string this.Value

module CustomerId =
    /// Admission and its reverse projection, packaged together.
    let refinement =
        Refinement.define
            (Constraint.greaterThan 0) // the rule over the underlying int
            CustomerId                 // wrap a value that passed
            _.Value                    // unwrap again, always

    let create value = Refinement.create refinement value

    let value (input: CustomerId) = input.Value
Requires & types
Requires: open Reified; open Reified.Refinements (packages: Reified.Constraint, Reified.Refinements)
val refinement: Refinement<int,CustomerId>
val create: int -> Result<CustomerId,Violation>
val value: CustomerId -> int

The case is private, so CustomerId.create is the only way in:

Verified
CustomerId.create 42   // Ok
CustomerId.create 0    // Error (Atomic (Expected (RelationAtom (Compared (GreaterThan, Integer 0L)), Some (Integer 0L))))

Use Constraint.all [ ... ] when several constraints describe admission, or Constraint.customWith for an invariant no built-in constraint describes, then hand either to the same Refinement.define. See Define Refined Types for both.

Give it the operations that justify it

CustomerId is a key; its operations support lookup and identity, not arithmetic:

Isolated
open Reified
open Reified.Refinements

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
    let value (input: CustomerId) = input.Value

    // ... as above

    /// Total: distinct ids stay distinct, so no entry can be lost.
    let index (customers: DistinctList<CustomerId>) = DistinctList.toSet customers

    /// Total: ids are ordered, so a range of them is an interval.
    let range (first: CustomerId) (second: CustomerId) = Interval.between first second

Both are total because ids are distinct and ordered, which is true of any comparable key — index and range would work for 0 too. What positivity buys is elsewhere: a 0 account number is a bug the boundary can reject once, and loadCustomer hands id.Value to a lookup without a guard. Neither operation involves arithmetic, which is where F# stops being able to carry the invariant for you. If a candidate type buys neither a total operation nor a removed branch, use a constraint instead.

Use it in domain code

Fragment
let loadCustomer (id: CustomerId) =
    // No guard: id is known to be above zero.
    repository.load id.Value

Keep the raw type at input and storage boundaries, and the refined type in between.

Give it a schema

Refinement.constraints exposes the same rule to Schema and other interpreters, so a boundary describes the type without restating it:

Verified
open Reified

let customerIdSchema : Schema<CustomerId> =
    Schema.int |> Schema.refine CustomerId.refinement
Requires & types
Requires: open Reified (packages: Reified.Schema)
val customerIdSchema: Schema<CustomerId>

Parsing checks the int, constructs the CustomerId, and reports failures at the field's path. Encoding projects back through Value. The emitted JSON Schema carries exclusiveMinimum: 0 because the constraint travelled with the refinement, you did not write the rule twice.

To resolve the type in a bare field without a withSchema, register it once as described in Refined Schemas, which works the same example through the Schema DSL and shows where schema-local constraints fit alongside it.

Next