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
- F# for Fun and Profit by Scott Wlaschin with an in-depth article series on understanding the F# type system.
- Video lectures at F# for Fun and Profit. Highly recommended for F# learners.
- The F# Language Guide over at Microsoft. This is more of an exhaustive and dense reference.
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.