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
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 -> intlet calculateSpeed (distance: int) (duration: int) =distance/duration
Using UoMs
type [<Measure>] kmtype [<Measure>] seconds// calculateSpeed: int<km> -> int<seconds> -> int<km/seconds>let calculateSpeed (distance: int<km>) (duration: int<seconds>) =distance/durationlet distance = 100<km>let duration = 10<seconds>calculateSpeed distance duration// Returns : int<km/seconds> = 10
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 subject = "Hey!"let body = "How are you?"// sendEmail: string -> string -> string -> unitlet sendEmail subject body email = // implementationsendEmail 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.UMXtype [<Measure>] emailAddresstype EmailAddress = string<emailAddress>// ^- This is optional, but makes type signatures clearer & more tersetype [<Measure>] bodytype Body = string<body>type [<Measure>] subjecttype Subject = string<subject>// sendEmail: Email -> Subject -> Body -> unitlet sendEmail (email: Email) (subject: Subject) (emailBody: Body) = // do stufflet 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
.
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 endtype SftpFileSystem = inherit IFileSystemtype LocalFileSystem = inherit IFileSystemtype FilePath<'filesystem when 'filesystem :> IFileSystem> = FilePath of stringlet 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:
# FromFilePath -> FilePath# ToFilePath<LocalFileSystem> -> FilePath<SftpFileSystem>
Here are a few examples from the application:
type IDataPrepper<'inFilesystem, 'outFilesystem when 'inFilesystem :> IFileSystem and 'outFilesystem :> IFileSystem> =// interface implementationlet sftpToLocalPrepper : IDataPrepper<SftpFileSystem, LocalFileSystem> = // implementation/// A generic file validator function that can be easily combinedtype IFileValidator<'filesystem when 'filesystem :> IFileSystem> =abstract Validate: FilePath<'filesystem> -> Result<FilePath<'filesystem>, exn>module SftpFileValidators =let freshnessValidator : IFileValidator<SftpFileSystem> = // implementationmodule LocalFileValidators =let freshnessValidator : IFileValidator<LocalFileSystem> = // implementation