Here is a quick recap on the F# type system focused on data types. This covers tuples, unions and records.

1. Unit type

The unit type is there to underpin that every F# expression must evaluate to a value. This is not unlike a void return in other languages.

Unit has only one possible value: ().

Some built-in functions such as printfn return unit. These functions work by side-effect alone.

Generics and unit

Unit is also allowed where a generic type is specified. In example: Result<unit, string> is accepted as the Result<'a, string> generic type.

As another example: Consider a case where we build a group of functions (monads) which are to return Result<'a * otherData, string>.

If for any reason a function should not return anything in place of 'a, a return type of Result<unit * otherData, string> is acceptable.

let resultExample =
    function
    | 1 -> Ok ()
    | x -> Error $"{x} instead of 1"

let resultExampleTuple =
    function
    | 1 -> Ok ((), 2)
    | x -> Error $"{x} instead of 1"

2. Type abbreviations (Aliases)

Types can be given another name. This is mostly a tool for code design and readability.

It is also useful for having a single place to edit a type used by multiple functions.

In this example ImageDimensions is simply an alias for int * int. Any int * int is a valid argument for the imageArea function:

type ImageDimensions = int * int

let imageArea (imgdim:ImageDimensions) : int =
    failwith "not implemented"
// val imageArea: int * int -> int

In this way, if it becomes necessary to change ImageDimensions to float * float, it is easy to do so.

3. Tuples

A tuple is a simple grouping of two or more unnamed values. These may be of the same or of different types.

// A basic tuple. 
type PairExample = int * int

// N many values, mixed types and generics are allowed. 
type 'a TripleExample = 'a * float * string

// Tuples can be nested. 
type NestedExample = int * (int * int)

Nesting differs from using multiple values:

// I.e. 
type ta = int * (int * int) 
// is not the same type as 
type tb = int * int * int 
// or even 
type tc = (int * int) * int

Using tuples

Tuple values are un-named. Pattern matching is the principal way to access them:

// Using all the values directly 1:
let boxVolume (l, w, h) = l * w * h

// Using all the values directly 2:
let boxTotalEdgeLength (l, w, h) = 
    (4 * l) + (4 * w) + (4 * h)

// Matching function on tuple: 
let divide = function
    | _, 0 -> Error "/ by 0"
    | dividend, divisor -> Ok (dividend / divisor)
// val divide: int * int -> Result<int,string>

// Function for the third value of a box. 
let height (_, _, h) = h

The language reference showcases tuple usage in even more detail.

Crucially this is not the same as multiple parameters to a function. A tuple is a single parameter.

Tuples are very useful when two or more values have a natural and close relationship, and are mostly used together in functions.

In contrast: If you find yourself constantly writing single value accessor functions for tuples, then perhaps a record type is more in order.

4. Records

Records can be thought of as a tuple with labeled values.

  • A record type is always declared before use.
  • In record expressions, the type of record is inferred by the labels.
  • All labels must be used. Use optional or result types as needed.
// A record type declaration:
type Person = {
    FirstName:  string
    MiddleName: string option
    LastName:   string
    Age:        int
}

// The corresponding record expression. 
let bloke = {
    FirstName  = "John"
    MiddleName = Some ("Guy")
    LastName   = "Doe"
    Age        = 33
}

// 'Person' is inferred unless there is a naming conflict, 
// else write 'Person.FirstName' to distinguish the labels. 

// Nice layout and ordering is optional. Semi-colon may be used instead of line-breaks. 

Using records

Dot notation is the principal way to access record values:

let fullName person =
    person.FirstName
    + " "
    + (person.MiddleName
       |> Option.bind (fun s -> Some (s + " "))
       |> Option.defaultValue "")
    + person.LastName

Here is another form of record pattern matching. See how the following code completes the same task:

let fullName2 person =
    let {
        FirstName = vorName
        MiddleName = mittelName
        LastName = nachName
    } = person
    
    // Same as fullName, but with other var-names: 
    vorName
    + " "
    + (mittelName
       |> Option.bind (fun s -> Some (s + " "))
       |> Option.defaultValue "")
    + nachName

5. Discriminated unions

Union types come close to what we are used to from type systems in other languages.

  • A union type must be defined before use with at least one union case.
    • Multiple union cases tend to have some shared behavior or use.
    • Case names start with a capital letter.
  • Each union case has a constructor function used in expressions of that case.
    • Through F# type inference, we normally only have to write the case name.
  • Functions that have a union type for a parameter should match and handle all union cases. Use optional or result types as needed.

In this code example, some shape data forms a common type:

type Shape =
    | Square of float
    | Circle of float
    | Rectangle of float * float


// Call to 'Shape.Rectangle' constructor function:
let rectangle1 = Rectangle (5.0, 7.0)

// union case Shape.Rectangle: float * float -> Shape

// 'Shape' is auto-inferred except if there is a naming conflict, 
// Else write 'Shape.Rectangle' to distinguish the constructor. 
  • These different cases include none or more components.
  • Cases with an identical set of components can still be pattern matched differently in a function.
  • Allowed components include any previously defined types, unit, generics, or even nothing at all.
  • Union case constructors are functions. So usable i.e. in List.map calls.
type Citizen =
    | Anonymous
    | Absolute of unit
    | Pensioner of Person

A union type does not need to have multiple cases. We may simply use it due to its relatively strict properties for type safety etc.:

type point1D = P of int
type line = L of int

// Parenthesis optional when parameter is a single value.
let aPoint = P 5
let aLine = L 27

// Different types -> Harder to accidentally switch up when passeing as parameters.

Using discriminated unions

Where a union type only has a single case, in-parameter deconstruction is common:

let rec points_1D_rev (L(w)) =
    match w with
    | 0 -> set [P(0)]
    | x -> points_1D_rev(L(w-1)).Add(P(x))

However, this can be a bad practice due to poor/non-existent naming of the parameter(s).

This other form deconstructs the parameter within the function, allowing it to be declared with the name line:

let rec points_1D_rev2 line =
    let (L w) = line
    match w with
    | 0 -> set [P(0)]
    | x -> points_1D_rev(L(w-1)).Add(P(x))

With multiple union cases; use a match statement to handle each case:

let area =
    function
    | Square side -> side ** 2
    | Circle radius -> (radius ** 2) * System.Math.PI
    | Rectangle (width, height) -> width * height

let circumference =
    function
    | Square side -> 4.0 * side
    | Circle radius -> 2.0 * radius * System.Math.PI
    | Rectangle (width, height) ->
        2.0 * width + 2.0 * height

Further reading

Notes

The code snippets are valid F#. Usable on an F# codepage or in F# interactive. For some sections (i.e. records) the code blocks are related and must be used together.