Basics 08 - Types (Part 1) - Type Aliasing, Records and Discriminated Unions

Time to discuss records and discriminated unions.

Learning-FSharp/Ch08-RecordsAndDiscriminatedUnions/Program.fs

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

Type Aliasing

Type aliasing allows you to have your names for existing types. For instance:

type Email = string
type Width = uint
type Height = uint
type Rectangle = Width * Height

With type aliasing you can:

  1. Have uniformity and consistent naming in the source

  2. Change the type definition in one place instead of making changes across the source code

With type aliasing you cannot:

  1. Make aliased type names available outside the world of F# (example: compiled in F#, not available in C#)

  2. Achieve single-case discriminated union capability for domain modeling (this is discussed below). For instance, type Email = string, here Email is just another name for string. Email type won't solve the problems that exist when using string for email addresses.

Records

Records allow the grouping of named values. Example:

type FullName = { First: string; Last: string }

  1. Type FullName is a record type

  2. It has 2 fields

  3. First of type string

  4. Last of type string

Record types can be created by using other record types as field types:

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

Construction and Updation

To create instances/values of records we use Record Expressions:

let fullName = { First = "John"; Last = "Doe" }

let student = { Name = { First = "John"; Last = "Doe" }; Age = 10; RollNo = "123456" }

Once created, fullName and student are fully immutable. You can't change fullName or student or value for any field.

We use Copy and Update Record Expressions to create a copy of an existing variable (or value) and supply new values for specific fields:

let newName = { fullName with First = "Jane" }

This creates a copy of fullName with new value for First.

Mutability of Fields

While it is possible to create record types with mutable fields, I highly recommend against it.

type FullNameMutable = { mutable First: string; Last: string }

let name = { First = "F"; Last = "L" }

name.First <- "new F"

There are a few cases where such a need may arise (performance-critical sections of code for instance). Typically, such an implementation goes against the idealogy of functional programming.

Records are not Classes

On the surface records and classes may seem similar, but there are some key differences in practice:

  1. There are no constructors, construction is done via Record Expressions

  2. Like classes, there are no fields, properties, getters/setters. Just fields.

  3. When comparing two instances of a class for equality, reference is what's compared. The Equals method is overridden to change this behavior. With records, equality comparison is based on structural equality, that is, to compare the value of fields.

Records with Members

You can create record types with member functions.

type Employee =
    { Name: FullName
      Age: int
      EmployeeId: string }

    // static member
    static member Create(first, last, age, employeeId) =
        { Name = { First = first; Last = last }
          Age = age
          EmployeeId = employeeId }

    // overridden Object.ToString
    override this.ToString() =
        $"Name: {this.Name}, Age: {this.Age}, RollNo: {this.EmployeeId}"

    // Instance member
    member this.WithNameInUpperCase() =
        { this with
            Name =
                { First = this.Name.First.ToUpper()
                  Last = this.Name.Last.ToUpper() } }
  1. Create is a static member function that returns Employee

  2. ToString and WithNameInUpperCase are instance member functions

  3. ToString is to override the default ToString implementation

  4. WithNameInUpperCase returns Employee using copy and update record expression

Anonymous Records

Anonymous records allow the creation of record values without any formal/prior definition.

let someRecord = {| Name = "Whatever"; Age = 0 |}

let someNestedRecord = {| Name = "Whatever"; Age = 0; Address = {| City = "City"; State = "State"; Country = "Country" |} |}

Anonymous records, like anonymous functions, should be created for one-off cases when the formal definition is overhead.

Discriminated Unions

Discriminated unions make F# very interesting. Simply put, discriminated unions are a combination of C-style enums and unions.

Enums: select one of many options. For instance, select one from the months, days of the week, or payment methods.

Unions: different data for different cases.

Here is a simple example: type IntOrString = | I of int | S of string

IntOrString is a discriminated union type. Values of type IntOrString can be in two states only. These states are also known as cases.

  1. Case I, when it holds an int value

  2. Case S, when it holds a string value

Here are a few more examples:

// int value for I case
// int * int for T case
type IntOrTuple =
    | I of int
    | T of int * int

// Only cases, no data association
type Day =
    | Monday
    | Tuesday
    | Wednesday
    | Thursday
    | Friday
    | Saturday
    | Sunday

// name of type string for NonEmployee case
// name of type string and employeeId of type string for Employee case
type Person =
    | NonEmployee of name: string
    | Employee of name: string * employeeId: string

Construction

Values of type discriminated unions are constructed by the case name, aka constructor function.

let a = I(1) // IntOrTuple.I

let b = T(1 * 2) // IntOrTuple.T

Values a and b are of type IntOrTuple.

let d = Monday

let e = Sunday

Values d and e are of type Day.

let f = NonEmployee("JFK")

let g = NonEmployee(name = "JFK")

let h = Employee("JFK", "12345")

let i = Employee(name = "JFK", employeeId = "12345")

Values f, g, h and i are all of type Person.

Discriminated Unions with Members

Similar to records, discriminated unions can have member functions. Please see above.

Problems and Limitations with Basic Data Types

Basic data types such as string and int have two major issues:

  1. Every string is just a string, regardless of what it contains. Imagine you have a function that accepts one parameter named email of type string. There is no direct and simple way to stop this function from being called for invalid email addresses. Any string can be passed to this function. Similarly, if there is a record with a field named Email of type string, there is no stopping from record construction with any string value.

  2. The second issue is a source of bugs. Look at the code given below. The function increaseHeight expects height, an int. However, when it is called, age is passed, which is also an int. There is no stopping this. This creates a bug, causing unexpected behavior.

     let increaseHeight h = h + 2
    
     let age = 10 // age in years
     let height = 100 // height in CM
    
     let newHeight = increaseHeight age
    

Before we discuss aswer to these problem, we need to go through few more basic concepts, which we shall in the coming chapters.

If you have reached so far, congratulations.

Keep reading!