How to do argument validation of F# records
Asked Answered
S

4

21

F# makes it easy to define types such as

type coords = { X : float; Y : float }

but how do I define constraints/check arguments for the constructor without going into the more verbose class definition syntax? E.g. if I want coords to start from (0,0) or throw an exception.

Moreover, if I change my definition to a class I need to implement Equals() etc. all the boiler plate code I don't want (and which I have in C# that I'm trying to get away from).

Seriatim answered 30/8, 2013 at 19:4 Comment(2)
possible duplicate of Is it possible to enforce that a Record respects some invariants?Ingunna
This is a duplicate of the other question. Vote to close.Miscellany
K
21

You can make the implementation private. You still get structural equality but you lose direct field access and pattern matching. You can restore that ability using active patterns.

//file1.fs

type Coords = 
  private { 
    X: float
    Y: float 
  }

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Coords =
  ///The ONLY way to create Coords
  let create x y =
    check x
    check y
    {X=x; Y=y}

  let (|Coords|) {X=x; Y=y} = (x, y)

//file2.fs

open Coords
let coords = create 1.0 1.0
let (Coords(x, y)) = coords
printfn "%f, %f" x y
Kala answered 30/8, 2013 at 20:35 Comment(3)
Thanks, my gut feeling tells me this is very convoluted and not a very typical approach to making types? I guess making a class and implementing Equals is preferable.. or enforce that all types in my program are constructed through some generic create method ?Seriatim
It's rare, yes, but only as convoluted as you make it. I think private implementation is a good feature, and useful for some admittedly uncommon scenarios.Kala
Ten years later, my use case today: normalizing a line segment so that the point to the left is first. I could do it in the constructor and guarantee that it's correct, or I could check every time. But you're right, it's surprising how rarely I've felt the need to do this in the past ~15 years.Nanette
C
9

There's a series called Designing with Types on F# for fun and profit. In section "Forcing use of the constructor" it recommends the use of constructor functions - that's where the validations go before the type is instantiated. To keep people from directly instantiating types it recommends either naming conventions or signature files.

You can find several more relevant articles and examples by googling "domain driven design f#".

Note that I'm coming from C# / not having applied F# to our domain layer (yet ;) I cannot really tell how either of the recommended methods would work out in a bigger project. Some things sure seem.. different in this brave new world.

Capacitance answered 30/8, 2013 at 23:35 Comment(0)
U
4

You have to use the class definition syntax:

type coords(x: float, y: float) =
  do
    if x < 0.0 then
      invalidArg "x" "Cannot be negative"
    if y < 0.0 then
      invalidArg "y" "Cannot be negative"

  member this.X =
    x
  member this.Y =
    y
Unboned answered 30/8, 2013 at 19:35 Comment(4)
if I change my definition to a class I need to implement Equals() etc. all the boiler plate code I don't want (and which I have in C# that I'm trying to get away from).Seriatim
Yes, you have to do that. If you want object-oriented encapsulation / data hiding you need object-oriented C#-like stuff, no way around that. The nice thing in F# is that you CAN do it.Unboned
so what would be the F# way? define a class or define a create function in the same module as the record and hope people use the create method?Seriatim
no need for elif (since invalidArg throws) but just if, and no need for elseSchlesinger
Y
1

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
        ()
Yusuk answered 2/10, 2020 at 18:56 Comment(5)
Certainly an interesting and creative approach, though the use of anonymous records prevents using match expressions (as do the private fields), and it limits the mimicked anonymous type to a bunch of other restraints of anonymous records. Just saying, it may be a useful approach in some scenarios, but certainly not all.Glorianna
That's true, about the limitations of anonymous records - thanks for pointing it out. There's certainly nothing that prevents people from using a combination of anonymous records, active patterns or some of the other approaches stated here if anonymous records themselves present too much of a downside. On my part, I felt like going through all of this to validate the creation of a record only to lose copy and update was too high a price.Yusuk
@Abel, after reflecting on your comment, I decided to wrap a standard record in a single case DU marked as private which seems to address the shortcomings of using anonymous records (because now its a standard record type) and still allows the same kind of validation when lifting the Coords into the single case DU. e.g. type CoordsRecord = { X : float Y : float } type Coords = private Coords of CoordsRecord ...then, the create function in the module can accept a CoordsRecord as an argument, validate it, and lift it into a Coords DU if it passesYusuk
that sounds interesting, perhaps you'd want to reflect that in your answer (you can edit it), if you feel that improves it. Most people won't read through all the comments ;).Glorianna
Thanks for the feedback, obv I'm an SO newbie. :-)Yusuk

© 2022 - 2024 — McMap. All rights reserved.