In a Bind with F#
While going through the F# exercism track, I stumbled upon F#’s bind
function.
One of my favorite things about Exercism is the ability to view other user’s published solutions. In this case, the most starred solution for Phone Number by alex123098 had an interesting solution using Result.bind
and Result.map
(check it out here). Thus begins our journey.
Note: For a more in-depth post, please check out Scott Wlaschin’s post on Understanding bind.
Post-Note: srpeterson shared a more advanced solution that uses domain modeling for additional type safety. You can view it here.
“Unbound” Solution
My original solution wasn’t the most functional or the easiest to reason through. I really disliked it. I really wanted a “railway oriented programming” way to attack this problem.
module PhoneNumber
open System
let clean (input: string) =
module PhoneNumber
open System
let clean (input: string) =
let validPunctuation = [ '-'; '('; ')'; '.' ]
let isInvalidPunctuation c =
Char.IsPunctuation c
&& not (List.contains c validPunctuation)
match input with
| phone when phone |> String.exists Char.IsLetter -> Error "letters not permitted"
| phone when phone |> String.exists isInvalidPunctuation -> Error "punctuations not permitted"
| phone ->
let removeCountryCode (phone: string) =
if phone.Length = 11 then phone.[1..] else phone
let filtered = phone |> String.filter Char.IsDigit
let noCountryCode = filtered |> removeCountryCode
match noCountryCode with
| _ when filtered.Length = 11
&& not (filtered.StartsWith('1')) -> Error "11 digits must start with 1"
| _ when filtered.Length > 11 -> Error "more than 11 digits"
| phone when phone.Length <> 10 -> Error "incorrect number of digits"
| phone when phone.StartsWith('0') -> Error "area code cannot start with zero"
| phone when phone.StartsWith('1') -> Error "area code cannot start with one"
| phone when phone.[3] = '0' -> Error "exchange code cannot start with zero"
| phone when phone.[3] = '1' -> Error "exchange code cannot start with one"
| phone -> (uint64 >> Ok) phone
So with this “good enough” solution to parse a phone number in mind, let’s look at what bind
gives us.
Binding the Solution
module PhoneNumber
open System
let private scrub (input: string) =
let validPunctuation = [ '('; ')'; '-'; '.'; '+'; ' ' ]
input
|> String.filter (fun c -> not (List.contains c validPunctuation))
let private validateLength input =
match String.length input with
| length when length < 10 -> Error "incorrect number of digits"
| length when length > 11 -> Error "more than 11 digits"
| _ -> Ok input
let private validateCountryCode input =
match String.length input with
| 11 when input.[0] <> '1' -> Error "11 digits must start with 1"
| 11 -> Ok input.[1..]
| _ -> Ok input
let private validateNoLetters input =
if String.exists Char.IsLetter input then Error "letters not permitted" else Ok input
let private validateNoPunctuation input =
if String.exists Char.IsPunctuation input then Error "punctuations not permitted" else Ok input
let private validateAreaCode (input: string) =
match input.[0] with
| '0' -> Error "area code cannot start with zero"
| '1' -> Error "area code cannot start with one"
| _ -> Ok input
let private validateExchangeCode (input: string) =
match input.[3] with
| '0' -> Error "exchange code cannot start with zero"
| '1' -> Error "exchange code cannot start with one"
| _ -> Ok input
let clean (input: string): Result<uint64, string> =
input
|> scrub
|> validateLength
|> Result.bind validateCountryCode
|> Result.bind validateNoLetters
|> Result.bind validateNoPunctuation
|> Result.bind validateAreaCode
|> Result.bind validateExchangeCode
|> Result.map uint64
Now we can read the clean
function and understand what it does at each step. Let’s take a closer look at the the signature of validateLength
and validateCountryCode
.
validateLength string -> Result<string, string>
validateCountryCode string -> Result<string, string>
Notice how validateLength
returns Result<string, string>
, yet it pipes into validateCountryCode
which accepts a string
.
🤯
So Result.bind
effectively allows us to avoid having to write functions like
let divide (result: Result<int * int, string>): Result<int, string> =
match result with
| Ok (_, 0) -> Error "divisor cannot be zero"
| Ok (dividend, divisor) -> Ok (dividend / divisor)
| Error e -> Error e
let add1 (result: Result<int, string>): Result<int, string> =
match result with
| Ok number -> Ok (number + 1)
| Error e -> Error e
Ok (10, 0) |> divide |> add1
// Error "divisor cannot be zero"
and instead write
let divide (numbers: int * int): Result<int, string> =
match numbers with
| (_, 0) -> Error "divisor cannot be zero"
| (dividend, divisor) -> Ok (dividend / divisor)
let add1 (number: int): Result<int, string> =
Ok (number + 1)
Ok (10, 0) |> Result.bind divide |> Result.bind add1
// Error "divisor cannot be zero"
While this is a simple example, hopefully it demonstrates how bind
helps us simplify the internals of our functions without losing context.