-
-
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" |
@david I guess throwing exceptions is not a FP-style. Besides in this case you only have possibility to validate one input (which might come to your ASP.net controller for example) and leave unvalidated the rest of inputs
@davidglassborow, apart from the issue with failwith
(this could be remedied by returning an option
or hiding it completely by just erroring whenever that function is used), the real problem with that approach is that you cannot access any members you would define on String50
. Suppose you have a tryCreate
member, or a value
member, you cannot get to it.
Alternatively, you can rename the typename to be different from the case name, but then, if someone uses a fully qualified name to access the case-onstructor, it can still do so.
@abelbraaksma agreed, it could return an option or Result, but as you say it hides members. Added comment on the fsharp-suggestion about private constructors
Instead of value
function, I'd prefer active pattern:
module A =
type MyRecord = private { Prop1: string; Prop2: int}
module MyRecord =
let Create prop1 prop2 = {Prop1 = prop1; Prop2 = prop2 }
let (|MyRecord|) {Prop1=prop1; Prop2=prop2} = struct (prop1, prop2)
Further to davidglassborow comment above and arthurborisow, there is the possibility to override the value constructors as follows... Microsoft (R) F# Interactive version 10.7.0.0 for F# 4.7
open System
type String50 =
| String50 of string
| Invalid50 of string
let String50 str =
if String.IsNullOrEmpty str then Invalid50 str
elif String.length str > 50 then Invalid50 str
else String50 str
let Invalid50 = String50
Results....
> String50 "";;
val it : String50 = Invalid ""
> String50 "sdfd";;
val it : String50 = String50 "sdfd"
> Invalid50 "";;
val it : String50 = Invalid ""
> Invalid50 "fsd";;
val it : String50 = String50 "fsd"
@gdennie, that works mostly, but you'll still loose any access to other members on the type, if there are any. Unless you rename the type different from the case name, but that opens the original value constructors again through qualified access.
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.
@swlaschin how about just rebinding the constructor, pattern matching still works as well.
e.g.