So far in the functional programming fundamentals blog series we've looked at pure functions, their benefits and immutability and expressions. My plan at this point was to tell you about partial functions, the benefits of total functions by comparison and a way of converting partial functions into total functions. Prash has already written that post and it's excellent! Check it out before reading this post.
Prash showed you one way to make partial functions total: extending the output set using the option
type. Today, in the final post of this series, we'll generalise that approach and discuss a different one: restricting the input set.
Recap
The example of a partial function that Prash gave was List.head : 'T list -> 'T
. It is partial because there are some 'T list
s for which the output value is not of the stated type, 'T
. In particular, List.head []
results in an exception.
Prash then compared it to the total function List.tryHead : 'T list -> 'T option
. It is total because no matter what input value you give it, you always get an output value of the stated type, never an exception. It is in some sense the same function as List.head
, but made total: if list
is non-empty, List.tryHead list
is Some (List.head list)
, so the output set is conceptually the same in either case, except that we've added a new value—None
—for input values that previously resulted in exceptions to map to.
Generalisation: extending the output set
This approach can be applied more generally: given a partial function, we can make a total function by adding new values to the output set, to which we can map input values that previously resulted in exceptions.
For example, you might have a function to parse a string as an email address
type Email = Email of string
open System
let parseEmailPartial s =
if String.IsNullOrWhiteSpace s then
failwith "Email cannot be empty"
else if (let chars = s :> seq<Char> in not (Seq.contains '@' chars)) then
failwith "Email must have '@'"
else
Email s
You can make this total by using the Result
type.
[<RequireQualifiedAccess>]
type EmailParsingFailure =
| Empty
| MissingAtSign
let parseEmailTotal s =
if String.IsNullOrWhiteSpace s then
Error EmailParsingFailure.Empty
else if (let chars = s :> seq<Char> in not (Seq.contains '@' chars)) then
Error EmailParsingFailure.MissingAtSign
else
Ok (Email s)
As in the previous example, valid input values are mapped to output values that are wrapped in a case signalling success and previously invalid input values are mapped to a value indicating invalid input, rather than throwing an exception. In this example, we've added two possible values to the output set, not just one. Now a developer using this function is prompted that the function might fail and can match the error cases to react appropriately.
Be careful not to take this approach too far: in many cases, you're better off using Exceptions.
Alternative: restricting the input set
We now know that partial functions can be made total by extending the output set. There is another option though: we can restrict the input set that the function takes so that the invalid values are removed.
Going back to the example of List.head
, we could make a NonEmptyList
type that guarantees that there is at least one element in the list and a corresponding NonEmptyList.head : NonEmptyList<'T> -> 'T
function. Since the input always has at least one element, we know that we can always get the first (head) element from it.
type NonEmptyList<'T> = private NonEmpty of 'T list
[<RequireQualifiedAccess>]
module NonEmptyList =
let tryCreate = function
| [] -> None
| list -> Some (NonEmpty list)
let head nonEmptyList =
match nonEmptyList with
| NonEmpty list -> List.head list
[ 1; 2 ]
|> NonEmptyList.tryCreate
|> Option.map NonEmptyList.head // 1.
NonEmptyList.head
is total: assuming they're created using NonEmptyList.tryCreate
, all input values result in a valid output value.
Summary
In this post we saw that there are two ways to convert partial functions into total functions: mapping previously invalid input values to new output values or prohibiting invalid input. Both techniques are useful for making your code more robust.