Basics 18 - Result Pipeline with Map and Bind

Learning-FSharp/Ch18-ResultPipeline/Program.fs

Learning-FSharp/Ch18-ResultPipeline/Comprehensive.fs

Check out all the comments that are placed in the source file.

Limitation of Option Container

In the last article, we discussed how to build a pipeline using the option container.

There is a major limitation with the option container. The option container has two cases; Some or None. When in None state, there is no reason or explanation. The Result container on the other hand has Ok of 'T case and Error of 'TError state, which means in Error case you can hold data. That data may be a string, an Exception, or any other piece of information.

The concepts which are used to build pipelines for the Result containers are the same as we used for option containers:

  1. Mapping functions: both input and output are real data; used with Result.map

  2. Binding functions: real input, lifted output; used with Result.bind

For the examples in this article, we will use Student record type:

type Student =
    { Name: string
      Age: int
      RollNo: string }

Validation Pipeline with Result.bind

Check out validateStudent function where the pipeline is implemented.

ent -> Result<Student, string>
let validateName (student: Student) =
    if String.IsNullOrWhiteSpace(student.Name) then
        Error "Name can't be empty!"
    else
        Ok student

// Student -> Result<Student, string>
let validateAge (student: Student) =
    if student.Age < 10 || student.Age > 20 then
        Error "Age must be between 10-20!"
    else
        Ok student

// Student -> Result<Student, string>
let validateRollNo (student: Student) =
    if String.IsNullOrWhiteSpace(student.RollNo) then
        Error "Roll number can't be empty!"
    else
        Ok student

// Student -> Result<Student, string>
let validateStudent (student: Student) =
    Ok student
    |> Result.bind validateName
    |> Result.bind validateAge
    |> Result.bind validateRollNo

// Validation Example 1

let student =
    { Name = "First Last"
      Age = 10
      RollNo = "Whatever" }

match validateStudent student with
| Ok(_) -> "Valid student record."
| Error(err) -> $"Invalid student record. {err}"
|> printfn "%s" // Valid student record.

// Validation Example 2

let student =
    { Name = ""
      Age = 10
      RollNo = "Whatever" }

match validateStudent student with
| Ok(_) -> "Valid student record."
| Error(err) -> $"Invalid student record. {err}"
|> printfn "%s" // Invalid student record. Name can't be empty!

// Validation Example 3

let student =
    { Name = "First Last"
      Age = 5
      RollNo = "Whatever" }

match validateStudent student with
| Ok(_) -> "Valid student record."
| Error(err) -> $"Invalid student record. {err}"
|> printfn "%s" // Invalid student record. Age must be between 10-20!

// Validation Example 4

let student =
    { Name = "First Last"
      Age = 10
      RollNo = "" }

match validateStudent student with
| Ok(_) -> "Valid student record."
| Error(err) -> $"Invalid student record. {err}"
|> printfn "%s" // Invalid student record. Roll number can't be empty!

Transformation Pipeline with Result.map

Check out transformStudent function where the pipeline is implemented.

let nameToUpper (student: Student) =
    { student with
        Name = student.Name.ToUpper() }

// Student -> Student
let agePlus5 (student: Student) = { student with Age = student.Age + 5 }

// Student -> Student
let rollnoToUpper (student: Student) =
    { student with
        RollNo = student.RollNo.ToUpper() }

// Student -> Student
let transformStudent (student: Student) =
    Ok student
    |> Result.map nameToUpper
    |> Result.map agePlus5
    |> Result.map rollnoToUpper

let student =
    { Name = "First Last"
      Age = 10
      RollNo = "Whatever" }

match transformStudent student with
| Ok(student) -> $"{student.Name}, {student.Age}, {student.RollNo}."
| Error(err) -> $"Invalid student record. {err}"
|> printfn "%s" // FIRST LAST, 15, WHATEVER.

Comprehensive Example

In this example we will do the following:

  1. Read a text file, existing_student.txt, which should have 3 lines of text

    1. Name

    2. Age

    3. RollNo

  2. Perform validations (as given in the validation example above)

  3. Perform transformations (as given in the validation example above)

  4. Write a text file, new_student.txt, with the same 3 lines as mentioned above

  5. Print success or failure details

File IO Functions

Here's the implementation of wrappers for System.IO.File.ReadAllLines and System.IO.File.WriteAllLines.

For details on tryFunc1 and tryFunc1, check out Basics 16 - Functions (Part 2).

// Wrapper for System.IO.File.ReadAllLines
// May raise exceptions
// string -> string array
// file -> lines
let readAllLinesImpl file = File.ReadAllLines(file)

// Wrapper for System.IO.File.WriteAllLines
// May raise exceptions
// string -> string array -> unit
// file -> lines -> unit
let writeAllLinesImpl file lines = File.WriteAllLines(file, lines)

// Reads the content of given text file as lines
// string -> Result<string array, exn>
let readAllLines = tryFunc1 readAllLinesImpl

// Writes the content (lines) for the specified text file
// string -> string array -> Result<unit, exn>
// file -> lines -> result
let writeAllLines = tryFunc2 writeAllLinesImpl

Validation and Transformation Functions

Here we have validation and transformation functions. Here we deal with Result<Student, exn>:

//////////////////////////
// Validation Functions //
//////////////////////////

// Student -> Result<Student, exn>
let validateName (student: Student) =
    if String.IsNullOrWhiteSpace(student.Name) then
        Error(Exception("Name can't be empty!"))
    else
        Ok student

// Student -> Result<Student, exn>
let validateAge (student: Student) =
    if student.Age < 10 || student.Age > 20 then
        Error(Exception("Age must be between 10-20!"))
    else
        Ok student

// Student -> Result<Student, exn>
let validateRollNo (student: Student) =
    if String.IsNullOrWhiteSpace(student.RollNo) then
        Error(Exception("Roll number can't be empty!"))
    else
        Ok student

//////////////////////////////
// Transformation Functions //
//////////////////////////////

// Student -> Student
let nameToUpper (student: Student) =
    { student with
        Name = student.Name.ToUpper() }

// Student -> Student
let agePlus5 (student: Student) = { student with Age = student.Age + 5 }

// Student -> Student
let rollnoToUpper (student: Student) =
    { student with
        RollNo = student.RollNo.ToUpper() }

Student (to/from) Text Lines

Here we have functions to convert Student to text lines and text lines to Student:

// Converts given lines of text to Student
// string array -> Result<Student, exn>
let linesToStudent (lines: string array) =
    let mutable age = 0

    if lines.Length = 3 && Int32.TryParse(lines[1], &age) then
        Ok
            { Name = lines[0]
              Age = age
              RollNo = lines[2] }
    else
        Error(Exception("Failed to parse file content."))

// Converts given student to lines of text
// Student -> string array
let studentToLines (student: Student) =
    [| student.Name; student.Age.ToString(); student.RollNo |]

The Pipeline

Finally, here's the pipeline:

// Pipeline implementation
// string -> string -> string
let comprehensivePipeline inputFile outputFile =
    readAllLines inputFile
    |> Result.bind linesToStudent
    |> Result.bind validateName
    |> Result.bind validateAge
    |> Result.bind validateRollNo
    |> Result.map nameToUpper
    |> Result.map agePlus5
    |> Result.map rollnoToUpper
    |> Result.map studentToLines
    |> Result.bind (writeAllLines outputFile)
    |> function
        | Ok(_) -> "All Done!"
        | Error(ex) -> $"Failed. {ex.Message}"

Once everything is ready, you may call:

comprehensivePipeline "existing_student.txt" "new_student.txt" |> printfn "%s"

Concluding Remarks

  1. The comprehensive example shown above covers a lot:

    1. File IO (which may fail)

    2. Student to/from text lines (which may fail)

    3. Validations (which may fail)

    4. Transformations

  2. Once you have your functions ready, stitching a pipeline isn't difficult

  3. Once the pipeline is written, it is elegant and easy to understand

If you have reached so far, congratulations.

Keep reading!