Weakly typed APIs can bite you, particularly when you’re using a language like F# where you’re for the most part blessed with strongly typed APIs. Thankfully, strongly typed wrappers are often one-liners.
This may seem obvious, but I have been bitten by this several times, so I just want to briefly share the problems and solutions.
Case 1: ToString/string/%O
ToString
(and by extension %O
in the printf
family of functions, as well as the string
function) works on any object. This means that things can easily break when you change the type of the value you call ToString()
on, since the string representation then likely changes but you don’t get a compilation error.
Say you have a domain model like this:
type Document = { Id: Guid }
You have a web API that provides access to a document, and the URL is constructed like this:
let getDocumentUrl (d: Document) =
sprintf "/documents/%O" d.Id
The system is in production, and all is well. But later you figure out that you’d like to avoid primitive obsession and follow the recommended practice of creating more strongly typed single-case DU wrappers. So you do this:
type DocumentId = DocumentId of Guid with
member this.Val = let (DocumentId x) = this in x
type Document = { Id: DocumentId }
This likely leads to plenty of wonderful compilation errors across your code base, and as is often the case in F#, when you have fixed them, things have a tendency to just work.
Not in this case though, because you don’t get a compilation error in getDocumentUrl
. This is because ToString
, and by extension %O
, will just as happily eat a DocumentId
as a Guid
. But now, your URLs look like /documents/DocumentId dc2f650d-...
instead of /documents/dc2f650d-...
. And if you don’t have a test in place to catch this before deploying… Well, let’s just say I have been burned by this.
One solution is to create a simple wrapper you can use instead of ToString/string/%O
. For example:
let guidToString (g: Guid) = g.ToString()
This is a function that calls ToString
, but only accepts a Guid
. You’d write similar functions for int
and any other primitive type you needed to stringify.
If you originally had the guidToString
helper, you would write getDocumentUrl
as
let getDocumentUrl (d: Document) =
sprintf "/documents/%s" (guidToString d.Id)
This time, however, you would have gotten a compilation error when you changed Document.Id
from Guid
to DocumentId
. Wonderful!
Update: Actually, you can simply supply a type argument to string
, so you don’t need to define guidToString
, intToString
, etc. For example:
let getDocumentUrl (d: Document) =
sprintf "/documents/%s" (string<Guid> d.Id)
Case 2: .Value property
If you write a lot of DU wrappers, you may write them with convenience inner value accessors like this:
type DocumentName = DocumentName of string with
member this.Value = let (DocumentName x) = this in x
Such an accessor is convenient because a property is often less verbose than destructuring. Furthermore, if the case is private because it’s a validated type, you can’t destructure it directly anyway.
But .Value
is a dangerous name to pick, because there’s another commonly used type that has a property of the same name: The Option
type.
To show how this can bite you, say you have this type:
type Document = { Name: DocumentName }
And somewhere, you use inner string
value of the document name in a weakly typed API that accepts obj
:
Log.Information(document.Name.Value)
(Yes, logging isn’t the most critical example, but you get the point.)
It’s in production and everything works, but now it turns out that not all documents have names. So you wrap Document.Name
in option
:
type Document = { Name: DocumentName option }
Again, you probably get a lot of compilation errors and fix them, but crucially, the document.Name.Value
usage above still compiles. However, now the .Value
property is Option.Value
, not DocumentName.Value
. Not only will the output be incorrect as described in Case 1; additionally, Option.Value
will throw an exception if the value is None
.
Again, I’ve been burned by this. Fool me once, shame on you. Fool me twice, time to refactor.
My solution is simply to use another name than Value
for the single-case DU accessors. I use , and I haven’t had the problem since. Update: I now use Val
because I haven’t seen that name anywhere and it’s shortvalue
instead. It matches the value
module function I always define that does the same thing (e.g. DocumentName.value
), as well as the name of a generic (SRTP) value
function I always define that calls .value
on the input.
Of course, no matter the name you choose, you can’t guarantee that you won’t ever come across the same problem. But wrapping something in Option
is a common refactoring, and by avoiding .Value
on my DU wrappers, I have eliminated this problem from my code.
I guess you could say that property accessors themselves are (semi-)weakly typed APIs, because a property accessor accepts a value of any type that has the property you’re trying to access. Normally it’s not a problem since the return type of the property is likely different, and you’d get a compilation error, but in this case, you feed the property value into an API accepting obj
, so you lose that safety check.
The moral
This is not a problem inherent to F#, but in F# we’re so used to the wonders of a flexible strong type system that it’s easy to forget certain pitfalls of .NET’s numerous weakly typed APIs.
Compile-time safety is great, but you have to use strongly typed APIs in order for it to be relevant.
Why not just do:
type
DocumentId = DocumentId
of
Guid
with
member
this.Val =
let
(DocumentId x) = this
in
x
override this.ToString() = this.Val
Then you don’t have to create or call simple wrapper?
Thanks for sharing your thoughts!
No, but you’d have to implement
ToString
for every single DU wrapper you create if you want to be sure. Most will never need it because they’ll never be used that way in the first place. I’d rather create a singleguidToString
or similar and use it only where it’s needed (a tiny subset of the usages of all my DU wrappers).Apart from that though, my point is to attempt to avoid weakly typed APIs (such as
ToString
) in the first place, because weakly typed APIs don’t save you. If you remember to overrideToString
, then you can just as well remember to check all usages of the type you’re changing and see if things are still correct.Does that make sense?
This is exact point is more pronounced in immutable vs mutable types, for example sharing an array in a record.
What do you mean?