Maximizing type-safety in F#

Two of the reasons I enjoy using statically-typed languages, are the compile-time safety and also the added context that types can provide. The ideal scenario is to minimise primitive obsession as much as possible, making type signatures richer.

F#'s type system boasts a powerful generics implementation which provides a means of parametric polymorphism. This provides us both flexibility and specificity in type signatures when desired.

Providing the 'right' types can be a balancing act, requiring us to choose between the type-safety of very specific types; the flexibility of generic types; the developer ergonomics and mental overhead from either; and potential performance penalties that come from wrapping in these types.

Luckily, there are a couple of tricks that can improve the experience with minimal downside - Units of measure & phantom types

Units of measure

What is a unit of measure?

If you're not fimilar with units of measure (UoM), this is a language feature that allows us to refine a numeric type to a specific measurement.

This refinement is erased at compile-time (no runtime overhead) and provides increased type-safety as it refines the type signature to both the numeric type (eg. int) but also to measurement.

Naive implementation

// calculateSpeed: int -> int -> int
let calculateSpeed (distance: int) (duration: int) =

Using UoMs

type [<Measure>] km
type [<Measure>] seconds
// calculateSpeed: int<km> -> int<seconds> -> int<km/seconds>
let calculateSpeed (distance: int<km>) (duration: int<seconds>) =
let distance = 100<km>
let duration = 10<seconds>
calculateSpeed distance duration
// Returns : int<km/seconds> = 10

Going further

This is all good and well for numerical domains, but it's application (at least for my usage) is very limited.

Luckily, FSharp.UMX extends this feature with the ability to apply units of measure to 'primitive non-numeric types'.

The main stand-out for me is the ability to apply UoMs to strings, as this provides a way of creating custom types without the overhead of actually defining records & discriminated unions, which incur development & runtime overhead.

Consider the following:

let emailAddress = "[email protected]"
let subject = "Hey!"
let body = "How are you?"
// sendEmail: string -> string -> string -> unit
let sendEmail subject body email = // implementation
sendEmail body email subject

The above version allows sending a value to the wrong parameter as all of the parameters are string.

However, using FSharp.UMX, we can limit the types with units of measure.

open FSharp.UMX
type [<Measure>] emailAddress
type EmailAddress = string<emailAddress>
// ^- This is optional, but makes type signatures clearer & more terse
type [<Measure>] body
type Body = string<body>
type [<Measure>] subject
type Subject = string<subject>
// sendEmail: Email -> Subject -> Body -> unit
let sendEmail (email: Email) (subject: Subject) (emailBody: Body) = // do stuff
let emailAddress : Email = %"[email protected]"
let subject : Subject = %"Hey!"
let body : Body = %"How are you?"
sendEmail subject body email // ok!
sendEmail body email subject // won't compile!

As you can see, simply using the % operator will allow a string to become a string<measure>, and it also works inversely to 'extract' the string from a UoM string.

It's worth noting that there is a trade-off when using an UoM vs. a 'full-fat' type (eg. record or discriminated union), but the choice depends on your requirements and your domain.

For example, you can't limit the construction of UoMs, nor can you add members to specific measures as the type is essentially still a string.

Phantom types

Another interesting use of F#'s generic system is that of 'phantom types'.

This is when you define a generic type (eg. Foo<'t>), but never actually implement the type specified by the by the parameter - it is only provided as a means of specificity.

For example, I recently had a task to move some files between between different machines, meaning I was dealing with the local filesystem and a remote SSH filesystem.

Initially I defined a FilePath type to work with.

type FilePath = FilePath of string
// Single-case discriminated union that wraps a string that represents the path

However, this type doesn't provide any information about the underlying filesystem - Is this a path on my local machine or the remote machine?

This lead to run-time issues when I tried to use the type. For example, I wanted to convert a FilePath into a System.IO.FileInfo, but inadvertently converted a path that was only present on the remote system.

This actually type-checked and constructed the FileInfo type just fine - despite the path not existing locally - but all of the metadata (eg. last updated time) was completely bogus. Oops!

To fix this, I refactored the FilePath to require a type parameter.

type IFileSystem = interface end
type SftpFileSystem = inherit IFileSystem
type LocalFileSystem = inherit IFileSystem
type FilePath<'filesystem when 'filesystem :> IFileSystem> = FilePath of string
let sftpPath : FilePath<SftpFileSytem> = FileSystem<_> "foo"
let localPath : FilePath<LocalFileSytem> = FileSystem<_> "bar"

I also decided that, instead of requiring any type for the type parameter, I wanted to restrict it a type that implements the IFileSystem interface. This forces the creation of specific types - eg. SftpFileSystem - to help provide a bit more domain context.

The IFileSystem interface - and those inheriting it, such as SftpFileSystem & LocalFileSystem - are 'marker interfaces' as they provide no additional members, and are only used as a way of 'tagging' types.

By refactoring the type, I also had to refactor any part of the application that used this type, and this helped enforce the added type-safety throughout. Now, all of my type signatures could be understood at a glance:

# From
FilePath -> FilePath
# To
FilePath<LocalFileSystem> -> FilePath<SftpFileSystem>

Here are a few examples from the application:

type IDataPrepper<'inFilesystem, 'outFilesystem when 'inFilesystem :> IFileSystem and 'outFilesystem :> IFileSystem> =
// interface implementation
let sftpToLocalPrepper : IDataPrepper<SftpFileSystem, LocalFileSystem> = // implementation
/// A generic file validator function that can be easily combined
type IFileValidator<'filesystem when 'filesystem :> IFileSystem> =
abstract Validate: FilePath<'filesystem> -> Result<FilePath<'filesystem>, exn>
module SftpFileValidators =
let freshnessValidator : IFileValidator<SftpFileSystem> = // implementation
module LocalFileValidators =
let freshnessValidator : IFileValidator<LocalFileSystem> = // implementation
Published April 20, 2021