Daniel's answer seems to be the closest to the "FP" approach, but one disadvantage is that we lose the ability to utilize other benefits records offer, such as copy and update. Since we now have anonymous records, it seems we could use those to work with the encapsulated object in a transparent way.
UPDATE: Abel suggested there are some downsides to using anonymous records (such as losing the ability to pattern match, etc.), so I used a combination of this approach with a private single case DU and a public record to address that concern.
// file1.fs
type Coords' =
{ X : float
Y : float }
type Coords = private Coords of Coords'
module Coords =
let private checkCoord (value : float) =
if value < 0.0 || value > 32.0 then invalidOp "Invalid coordinate"
let create (newcoord : Coords') =
checkCoord newcoord.X
checkCoord newcoord.Y
newcoord |> Coords
let value (Coords c) = c
// file2.fs
open File1
module Tests =
[<Test>]
let Test0 () =
let firstcoord = Coords.create {X = 5.0; Y = 6.0}
let secondcoord = Coords.create {(firstcoord |> Coords.value) with X = 10.0}
let thirdcoord = Coords.value secondcoord
Assert.IsTrue (thirdcoord.X = 10.0)
Assert.IsTrue (thirdcoord.Y = 6.0)
Assert.Pass ()
[<Test>]
let Test1 () =
{X = 0.0; Y = 0.0} |> Coords //Doesn't compile
()