How do I convert this code from using two separate types into code which uses a DU?

I started off with the code below, with which I can create two types of very simplistic bank accounts: PersonalAccount and JointAccount.
PersonalAccounts are for one person, JointAccounts are for two people to share.
When a JointAccount is created, most of the balances in both accounts are moved to the joint account but each person is left with a small amount (residual) in the new version of their personal account.
Only PersonalAccounts can be ‘joined’ into a JointAccount.

let minimumResidual = 50.0m

let calculateResidual balance = if balance < minimumResidual then balance else minimumResidual

type BasicAccountDetails =
    { Balance: decimal }

type JointAccount =
    { Primary: string
      Secondary: string
      Balance: decimal }

type PersonalAccount =
    { Name: string
      Balance: decimal }
    member this.makeJointWith (addition: PersonalAccount) =
        let thisResidual = calculateResidual this.Balance
        let additionResidual = calculateResidual addition.Balance
        ( { Primary = this.Name
            Secondary = addition.Name
            Balance = (this.Balance - thisResidual) + (addition.Balance - additionResidual) } ,
          { this with Balance = thisResidual },
          { addition with Balance = additionResidual } )
    
let janesAccount = { Name = "Jane"; Balance = 200.0m }
let bobsAccount = { Name = "Bob"; Balance = 40.0m }

let jointAccount, janesNewAccount, bobsNewAccount = janesAccount.makeJointWith bobsAccount

That works fine but, since PersonalAccount and JointAccount are different types, they can’t be put into a collection together.

I then converted the code into what you can see next (don’t look if bad code makes you cry):

let maximumResidual = 50.0m

let calculateResidual balance = if balance < maximumResidual then balance else maximumResidual

type BasicDetails =
    { Balance: decimal }

type PersonalDetails =
    { Name: string }

type JointDetails =
    { Primary: string
      Secondary: string }

type Account =
    | Personal of BasicDetails * PersonalDetails
    | Joint of BasicDetails * JointDetails
    
let janesAccount = Personal ({ Balance = 200.0m }, { Name = "Jane"})
let bobsAccount = Personal ({ Balance = 40.0m }, { Name = "Bob"})

let makeJointAccount (primary: Account) (secondary: Account) =
    match primary with
        | Personal (primaryBasic, primaryPersonal) -> 
            let primaryResidual = calculateResidual primaryBasic.Balance
            match secondary with
                | Personal (secondaryBasic, secondaryPersonal) ->
                    let secondaryResidual = calculateResidual secondaryBasic.Balance
                    ( Joint ({Balance = (primaryBasic.Balance - primaryResidual) + (secondaryBasic.Balance - secondaryResidual)},
                        { Primary = primaryPersonal.Name
                          Secondary = secondaryPersonal.Name } ),
                      Personal ({ primaryBasic with Balance = primaryResidual }, primaryPersonal ),
                      Personal ({ secondaryBasic with Balance = secondaryResidual }, secondaryPersonal) )
                | _ -> primary, primary, primary
        | _ -> primary, primary, primary

let jointAccount, janesNewaccount, bobsNewAccount = makeJointAccount janesAccount bobsAccount

As you can see, it’s horrible code which, while it returns the things I want when it runs under certain circumstances, it’s really not the right way to do things (returning the primary account three times when either account is not a Personal account is clearly utterly wrong).

I just can’t seem to figure out how to make makeJointAccount only accept Personal accounts; changing (primary: Account) to (primary: Personal) gives me an error that ‘Personal’ is not defined.
I’ve probably gone too far down the wrong route and can’t see my way back to where I need to be; the ‘fix’ is probably very simple.

Can anyone give me some suggestions for turning this pigs ear into a silk purse (or even a linen coin bag)?
The code is completely experimental so any changes can be done to it; nothing is necessary and any changes are welcome.

Short answer: I would start with your original code, and then just add an additional type for the union:

type Account = 
  | Personal of PersonalAccount
  | Joint of JointAccount

and then you can lump all accounts together (as Accounts) but still have operations that are only valid on PersonalAccounts. Use the Personal or Joint functions to turn a PersonalAccount or JointAccount into an Account, and use pattern matching if you have an Account and want to get back to a PersonalAccount or JointAccount. e.g., if you have a pair of Accounts, you’d have to pattern match in order to call the makeJointWith method:

let ans = 
  match acct1, acct2 with
  | Personal p1, Personal p2 -> Ok <| p1.makeJointWith p2
  | Joint acct, _ 
  | _, Joint acct -> Error <| sprintf "%s's account is already a joint account and cannot be combined" acct.Primary

or alternatively, throw an Exception if that’s how you’re handling error cases.
You could take a list of accounts and extract the personal accounts:

let accounts : Account list = ...
let personalAccounts : PersonalAccount list = 
  accounts |> List.choose (fun acct -> match acct with | Personal p -> Some p | _ -> None)
// or shorthand
let personalAccounts : PersonalAccount list = 
  accounts |> List.choose (function | Personal p -> Some p | _ -> None)

Longer answer:
In F#, a type can be thought of as a set of possible values that something could take on. Sometimes that set is finite (the bool set only contains 2 values: true and false), sometimes that set is infinite or practically so (the string set contains every possible string, which certainly is not enumerable). When you say

type Account = 
  | Personal
  | Joint

(I’m leaving off the associated values for now), I’m creating 3 things:
1 type named Account, and
2 values named Personal and Joint.

The Account type can be thought of as a set of possible values - in this case 2 values, Personal and Joint. (At this point, I could rename Account/Personal/Joint to bool/true/false, and I’ve just reinvented the boolean type).

Types and values are very much separate - in any context you can only refer to one or the other. That’s why you’re getting an error saying Personal is not defined. You’re in a context that you can only talk about types and there is no type named Personal that’s defined. Only a Personal value. It would be like trying to include true in a type signature.

Whenever you want to define some operation that is only valid on some subset of values (like makeJointAccount which is only valid on 2 personal accounts), you need to define a type for the possible values that are allowed - so some sort of type PersonalAccount will need to exist.

Hope that helps!

Thanks very much for the explanation.
There are some things I haven’t looked at yet, e.g. backwards pipe, Result, and the ‘function’ keyword, but there’s lots for me try.
Your explanation of types, in relation to values, is particularly useful and may help me to unlock some little mysteries that I haven’t managed to get my head properly around so far.
Thanks again.

1 Like