Several ducks and duck-like beings in Victoria Park, London. Photo: Jess Hurd
We all know that F# is statically typed, which is really great until it's inconvenient and constraining. What if you could write a function that can take any type that you pass in as long as it has the property .Length
, returning an int
, for example? Well, you can.
This is known as "duck typing":
If it walks like a duck and it quacks like a duck, then it must be a duck. 🦆
In the F# context we would translate this to:
If a value has the members (properties and methods) that we expect it to have, then we can accept it as a function parameter and access those members without saying it must have one specific type 👩🏾💻
How do we achieve duck typing in F#?
We can do this with statically resolved type parameters (SRTPs). This is a fairly advanced language feature with a slightly awkward syntax, which I definitely do not recommend that you consider as a first option. I can never remember the full SRTP syntax but I have created what I believe to be the most minimal example of it:
let inline Length x = (^a : (member Length : _) x)
💡 Note the use of the
inline
keyword above. This is needed because the SRTP feature isn't supported natively by .NET, so the function calls will actually be inlined to their call site at compile time.
The above function which I've named Length
accepts any value which has a property Length
, regardless of the type of that property, and returns its value. So this means it works on both a string and a list:
Length "text" // 4
Length [ 1; 2; 3 ] // 3
Spreading the duck typing to a more useful function
Now what if we want to make use of this in a function that does a little bit more? This is quite easy to do and the really good news is that we don't need to write any more SRTP syntax! Consider this function that describes the length of a value using our Length
function above:
let inline describeLength x =
$"This has a length of %i{Length x}"
(*
The function type reported by FSI:
val inline describeLength:
x: ^a -> string
when ^a: (member get_Length: ^a -> ^b) and
^b: (byte|int16|int32|int64|sbyte|uint16|uint32|uint64|nativeint|
unativeint)
*)
The function definition itself is very simple. You write it exactly like you would write any other simple function, but you include the inline
keyword. We don't need to add any SRTP syntax explicitly, but the compiler will infer all of the SRTP constraints automatically. You can see the lengthy inferred function type above. The compiler knows that this function takes a value which has a property Length
, which returns one of the many types supported by the %i
format specifier. It has done a lot of work for us.
And here's the function in action:
describeLength "text" // "This has a length of 4"
describeLength [ 1; 2; 3 ] // "This has a length of 3"
Combining different duck typing functions
If we want to write a function that works for a type that contains two different properties we can use our existing approach and extend it further:
let inline Length x = (^a : (member Length : _) x)
let inline Head x = (^a : (member Head : _) x)
let inline HeadLength x = x |> Head |> Length
(*
val inline HeadLength:
x: ^a -> 'c
when ^a: (member get_Head: ^a -> ^b) and
^b: (member get_Length: ^b -> 'c)
*)
HeadLength [ [ 1 ] ] // 1
HeadLength [ "text" ] // 4
HeadLength [ 1; 2; 3 ] // error FS0001: The type 'int' does not support the operator 'get_Length'
First we define two small and simple SRTP functions, one that accesses Length
and another that accesses Head
. Then we write another very simple inline
function that uses both of these functions. Again, the compiler automatically infers the necessary types with SRTPs.
It would be possible to write HeadLength
completely independently without using the smaller and simpler Length
and Head
functions, but you would need to manually write out more complex type constraints, which is why I wouldn't generally recommend it.
Duck typing on methods
So far we've only looked at properties, but we can use duck typing on methods too. The approach is similar, but the syntax is slightly different. Again, here's the simplest example I could come up with:
let inline ToString a x = (^x : (member ToString : ^a -> _) (x, a))
(*
val inline ToString:
a: ^a -> x: ^x -> 'a0 when ^x: (member ToString: ^x * ^a -> 'a0)
*)
open System
1 |> ToString "c" // "£1.00"
DateTime.Now |> ToString "s" // "2023-02-24T14:04:37"
DateTime.Now |> ToString Globalization.CultureInfo.CurrentCulture // "24/02/2023 14:04:37"
Note how this even works when calling different overloads of .ToString()
Should I even use this? 😅
Whenever you use the more advanced feature of F#, it's probably worth asking yourself if you should really use it rather than stick with something simple. SRTPs are definitely fun to play with but I tend to limit their use in production code for a few reasons. Heavy use of SRTPs can significantly increase compile times and also make code harder to understand. There is at least one scenario where they are quite useful: working with type providers.
Working with provided types
Suppose we have two different provided types that have common fields—that is, there are fields with the same name and type on both provided types. We might like to write a function that can accept both types and work with those common fields. Here's a full example of this using the JSON type provider from FSharp.Data:
#r "nuget: FSharp.Data, 5.0.2"
open FSharp.Data
type NameInfoJson = JsonProvider<"""{ "firstName": "David", "lastName": "Hawkins" }""">
let nameInfo = NameInfoJson.GetSample()
type PersonDetailsJson = JsonProvider<"""{
"firstName": "David",
"middleInitial": "R",
"lastName": "Hawkins",
"age": 85
}""">
let personDetails = PersonDetailsJson.GetSample()
type Name = { FirstName : string; LastName : string }
let inline FirstName x = (^a : (member FirstName : _) x)
let inline LastName x = (^a : (member LastName : _) x)
let inline toPerson x = { FirstName = FirstName x; LastName = LastName x }
toPerson nameInfo // { FirstName = "David"; LastName = "Hawkins" }
toPerson personDetails // { FirstName = "David"; LastName = "Hawkins" }
See how the toPerson
function takes values of two different types from the two provided types and produces the same value from both of them. Very handy.
All ducked up
As you can see, it's possible to keep the use of SRTPs fairly simple and they can be quite useful at times. See what other use cases you might have for static duck typing. Please use this responsibly and enjoy! 🦆