Skip to content

Instantly share code, notes, and snippets.

@swlaschin
Last active November 5, 2024 23:28
Show Gist options
  • Save swlaschin/54cfff886669ccab895a to your computer and use it in GitHub Desktop.
Save swlaschin/54cfff886669ccab895a to your computer and use it in GitHub Desktop.
Examples of creating constrained types in F#
// 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"
@juliusdeblaaij
Copy link

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

@wekempf
Copy link

wekempf commented Oct 28, 2024

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment