-
-
Save swlaschin/54cfff886669ccab895a to your computer and use it in GitHub Desktop.
// General hints on defining types with constraints or invariants | |
// | |
// Just as in C#, use a private constructor | |
// and expose "factory" methods that enforce the constraints | |
// | |
// In F#, only classes can have private constructors with public members. | |
// | |
// If you want to use the record and DU types, the whole type becomes | |
// private, which means that you also need to provide: | |
// * a constructor function ("create"). | |
// * a function to extract the internal data ("value"). | |
// | |
// This is marginally annoying, but you can always use the C# approach if you like! | |
// | |
// OTOH, your opaque types really ARE opaque! | |
// | |
// The other alternative is to use signature files (which are not discussed here) | |
module ConstrainedTypes = | |
open System | |
// --------------------------------------------- | |
// Constrained String50 (FP-style) | |
// --------------------------------------------- | |
/// Type with constraint that value must be non-null | |
/// and <= 50 chars. | |
type String50 = private String50 of string | |
/// Module containing functions related to String50 type | |
module String50 = | |
// NOTE: these functions can access the internals of the | |
// type because they are in the same scope (namespace/module) | |
/// constructor | |
let create str = | |
if String.IsNullOrEmpty(str) then | |
None | |
elif String.length str > 50 then | |
None | |
else | |
Some (String50 str) | |
// function used to extract data since type is private | |
let value (String50 str) = str | |
// --------------------------------------------- | |
// Constrained String50 (object-oriented style) | |
// --------------------------------------------- | |
/// Class with constraint that value must be non-null | |
/// and <= 50 chars. | |
type OOString50 private(str) = | |
/// constructor | |
static member Create str = | |
if String.IsNullOrEmpty(str) then | |
None | |
elif String.length str > 50 then | |
None | |
else | |
Some (OOString50(str)) | |
/// extractor | |
member this.Value = str | |
// --------------------------------------------- | |
// Constrained AtLeastOne (FP style) | |
// --------------------------------------------- | |
/// Type with constraint that at least one of the fields | |
/// must be set. | |
type AtLeastOne = private { | |
A : int option | |
B : int option | |
C : int option | |
} | |
/// Module containing functions related to AtLeastOne type | |
module AtLeastOne = | |
/// constructor | |
let create aOpt bOpt cOpt = | |
match aOpt,bOpt,cOpt with | |
| (Some a,_,_) -> Some <| {A = aOpt; B=bOpt; C=cOpt} | |
| (_,Some b,_) -> Some <| {A = aOpt; B=bOpt; C=cOpt} | |
| (_,_,Some c) -> Some <| {A = aOpt; B=bOpt; C=cOpt} | |
| _ -> None | |
// This might fail, so return option -- caller must test for None | |
// These three always succeed, no need to test for None | |
let createWhenAExists a bOpt cOpt = | |
{A = Some a; B=bOpt; C=cOpt} | |
let createWhenBExists aOpt b cOpt = | |
{A = aOpt; B=Some b; C=cOpt} | |
let createWhenCExists aOpt bOpt c = | |
{A = aOpt; B=bOpt; C=Some c} | |
// function used to extract data since type is private | |
let value atLeastOne = | |
let a = atLeastOne.A | |
let b = atLeastOne.B | |
let c = atLeastOne.C | |
(a,b,c) | |
// --------------------------------------------- | |
// Constrained AtLeastOne (object-oriented style) | |
// --------------------------------------------- | |
/// Class with constraint that at least one of the fields | |
/// must be set. | |
type OOAtLeastOne private (aOpt:int option,bOpt:int option,cOpt:int option) = | |
/// constructor | |
static member create(aOpt,bOpt,cOpt) = | |
match aOpt,bOpt,cOpt with | |
| (Some a,_,_) -> Some <| OOAtLeastOne(aOpt,bOpt,cOpt ) | |
| (_,Some b,_) -> Some <| OOAtLeastOne(aOpt,bOpt,cOpt ) | |
| (_,_,Some c) -> Some <| OOAtLeastOne(aOpt,bOpt,cOpt ) | |
| _ -> None | |
// These three always succeed, no need to test for None | |
static member createWhenAExists(a,bOpt,cOpt) = | |
OOAtLeastOne(Some a,bOpt,cOpt) | |
static member createWhenBExists(aOpt,b,cOpt) = | |
OOAtLeastOne(aOpt,Some b,cOpt) | |
static member createWhenCExists(aOpt,bOpt,c) = | |
OOAtLeastOne(aOpt,bOpt,Some c) | |
member this.Value = | |
(aOpt,bOpt,cOpt) | |
// --------------------------------------------- | |
// Constrained DU (FP style) | |
// --------------------------------------------- | |
/// DU with constraint that classification must be done correctly | |
type NumberClass = | |
private | |
| IsPositive of int // int must be > 0 | |
| IsNegative of int // int must be < 0 | |
| Zero | |
/// Module containing functions related to NumberClass type | |
module NumberClass = | |
let create i = | |
if i > 0 then IsPositive i | |
elif i < 0 then IsNegative i | |
else Zero | |
// active pattern used to extract data since type is private | |
let (|IsPositive|IsNegative|Zero|) numberClass = | |
match numberClass with | |
| IsPositive i -> IsPositive i | |
| IsNegative i -> IsNegative i | |
| Zero -> Zero | |
// ====================================================== | |
// This client attempts to use the types defined above | |
// ====================================================== | |
module Client = | |
open ConstrainedTypes | |
// --------------------------------------------- | |
// Constrained String50 (FP-style) | |
// --------------------------------------------- | |
let s50Bad = String50 "abc" | |
// ERROR: The union cases or fields of the type 'String50' are not accessible from this code location | |
let s50opt = String50.create "abc" | |
s50opt | |
|> Option.map String50.value | |
|> Option.map (fun s -> s.ToUpper()) | |
|> Option.iter (printfn "%s") | |
// --------------------------------------------- | |
// Constrained String50 (object-oriented style) | |
// --------------------------------------------- | |
let ooS50Bad = OOString50("abc") | |
// ERROR: This type has no accessible object constructors | |
let ooS50opt = OOString50.Create "abc" | |
ooS50opt | |
|> Option.map (fun s -> s.Value) | |
|> Option.map (fun s -> s.ToUpper()) | |
|> Option.iter (printfn "%s") | |
// --------------------------------------------- | |
// Constrained AtLeastOne (FP style) | |
// --------------------------------------------- | |
let atLeastOneBad = {A=None; B=None; C=None} | |
// ERROR: The union cases or fields of the type 'AtLeastOne' are not accessible from this code location | |
let atLeastOne_BOnly = AtLeastOne.create None (Some 2) None | |
match atLeastOne_BOnly with | |
| Some x -> x |> AtLeastOne.value |> printfn "%A" | |
| None -> printfn "Not valid" | |
let atLeastOne_AOnly = AtLeastOne.createWhenAExists 1 None None | |
let atLeastOne_AB = AtLeastOne.createWhenAExists 1 (Some 2) None | |
atLeastOne_AB |> AtLeastOne.value |> printfn "%A" | |
// --------------------------------------------- | |
// Constrained AtLeastOne (OO style) | |
// --------------------------------------------- | |
let ooAtLeastOneBad = OOAtLeastOne(None, None, None) // This type has no accessible object constructors | |
let atLeastOne_BOnly = OOAtLeastOne.create(None,Some 2,None) | |
match atLeastOne_BOnly with | |
| Some x -> printfn "%A" x.Value | |
| None -> printfn "Not valid" | |
let ooAtLeastOne_AOnly = OOAtLeastOne.createWhenAExists(1,None,None) | |
let ooAtLeastOne_AB = OOAtLeastOne.createWhenAExists(1,Some 2,None) | |
ooAtLeastOne_AB.Value |> printfn "A=%A" | |
// --------------------------------------------- | |
// Constrained DU (FP style) | |
// --------------------------------------------- | |
// attempt to create a bad value | |
let numberClassBad = IsPositive -1 | |
// ERROR: The union cases or fields of the type 'NumberClass' are not accessible from this code location | |
let numberClass = NumberClass.create -1 | |
// this fails because the DU cases are not accessible | |
match numberClass with | |
| IsPositive i -> printfn "%i is positive" i | |
| IsNegative i -> printfn "%i is negative" i | |
| Zero -> printfn "is zero" | |
open NumberClass // bring active pattern into scope | |
// this works because the active pattern is being used. | |
match numberClass with | |
| IsPositive i -> printfn "%i is positive" i | |
| IsNegative i -> printfn "%i is negative" i | |
| Zero -> printfn "is zero" |
When dealing with record types, you might need to add public getters like here.
I really wish F# had something built-in for this, because this is really complicated. So many ways to try and do it, and many of those ways have subtle issues. Let's just look at one from here.
/// Type with constraint that at least one of the fields
/// must be set.
...
Looks great, and absolutely seems to do what you want, until...
let ao = AtLeastOne.create None None (Some 1)
let oops = { ao with C = None }
We've just created invalid state. :(
Here's a pattern for this that hopefully addresses all concerns. I'm going to use FsToolkit to do validation during creation.
open FsToolkit.ErrorHandling
// Use a module for constrained type
module User =
// How to describe an "unvalidated"/unconstrained type. This can be a simple type like string or int as well.
type Descriptor = { First: String; Last: String }
// The "opaque" constrained type
type T = private T of Descriptor
// validation helper
let private validateName name n =
if String.IsNullOrWhiteSpace(n) then Error <| sprintf "%s name can't be empty" name
else Ok n
// "Constructor" that may fail validation
let tryMk desc =
validation {
let! first = validateName "First" desc.First
and! last = validateName "Last" desc.Last
return T desc
}
// "Constructor" that throws when validation fails.
let mk desc =
match tryMk desc with
| Error e -> failwith (["Invalid user:"] @ e |> String.concat "\n ")
| Ok t -> t
// Extract the Descriptor from the opaque type
let desc (T user) = user
// Other functions that work with the type can be declared here
// Active pattern for our type
let (|User|) user = User.desc user
let scott = User.mk { First = "Scott"; Last = "Wlaschin" }
let msg =
match scott with
| User { First = "Scott" } -> "It's a Scott!"
| _ -> "Not a Scott. :("
printfn "%s" msg
Here's a pattern for this that hopefully addresses all concerns. I'm going to use FsToolkit to do validation during creation.
open FsToolkit.ErrorHandling // Use a module for constrained type module User = // How to describe an "unvalidated"/unconstrained type. This can be a simple type like string or int as well. type Descriptor = { First: String; Last: String } // The "opaque" constrained type type T = private T of Descriptor // validation helper let private validateName name n = if String.IsNullOrWhiteSpace(n) then Error <| sprintf "%s name can't be empty" name else Ok n // "Constructor" that may fail validation let tryMk desc = validation { let! first = validateName "First" desc.First and! last = validateName "Last" desc.Last return T desc } // "Constructor" that throws when validation fails. let mk desc = match tryMk desc with | Error e -> failwith (["Invalid user:"] @ e |> String.concat "\n ") | Ok t -> t // Extract the Descriptor from the opaque type let desc (T user) = user // Other functions that work with the type can be declared here // Active pattern for our type let (|User|) user = User.desc user let scott = User.mk { First = "Scott"; Last = "Wlaschin" } let msg = match scott with | User { First = "Scott" } -> "It's a Scott!" | _ -> "Not a Scott. :(" printfn "%s" msg
Hello!
I saw your remark about the with keyword circumventing constructor validation. "We've just created invalid state."
I'm wondering whether your implementation above has a way to get around this issue?
Kind regards,
Julius
@juliusdeblaaij Interestingly, it appears to.
let changed = { scott with First = "Bill" }
The record label 'First' is not defined.
If it didn't, you could make User.T
entirely opaque using a signature file, but it doesn't appear to be necessary.
@gdennie see further discussions on this in the fs-lang suggestions repo here