What I Love about F#'s Type System
Now I know my A -> B 's, see? Next time won't you sing with me? Too much?
'a -> 'b
It is such a beautiful thing.
'a -> 'b
, where 'a
and 'b
are generic types. That function signature represents every possible function in F#.
On it's face it seems small and insignificant. But often when you have something simple and small you can use it as foundation for much greater things.
Take a "decorator" function that logs the input and output of calling a function passed as an argument.
let callWithLogging (fn : 'a -> 'b) (x : 'a) : 'b =
log x
let result = fn x
log result
result
callWithLogging
takes a function that you want to log the input and output of, and returns a function that takes the same input, 'a
, as the wrapped function.
Because of F#'s type system, callWithLogging
can wrap any function in F# and log its input and output. The same cannot be said for almost any other language I've seen (excluding those sharing F#'s ML heritage).
The Gauntlet
Almost every programming language I've encountered struggles to play nicely with functions at some level. The problems I run into most often are the following:
Function Arity
The first problem we run into in many languages is functions that take more than one argument.
Lets use C# as an example. Here is callWithLogging
translated to C#:
public B CallWithLogging<A, B>(Func<A, B> fn) => (A x) => {
log x;
var result = fn(x);
log result;
return result;
}
What happens when we want to decorate an "add" function? Something like
public int Add(int x, int y) => x + y;
Our code won't compile because Add
is a Func<int, int, int>
, which won't work with Func<A, B>
because Func<A, B>
only expects one argument and Add
expects two.
We could change our C# Add
function to make it work:
public int Add((int, int) xy) => xy.Item1 + y.Item2;
This version will work with CallWithLogging
because instead of Func<int, int, int>
, now we have a Func<(int, int), int>
. Since we've modified Add
to only expect one argument, Func<A, B>
is happy. Our A
is a (int, int)
tuple and our B
is an int
.
Why doesn't F# have this problem?
Because every F# function only takes one argument.
Lets take a look. Here's the add
function in F#:
let add (x : int) (y : int) : int = x + y;
It looks like this function takes two arguments, but it doesn't. add
s signature is int -> int -> int
, and anything after a ->
in F# is a return type. It might be easier to see if we wrap the return type in parenthesis, int -> (int -> int)
. It's a function that returns a function, and the first function just takes one int
.
If we wanted to pass both int
s at the same time, we could write:
let add (xy : x * y) : int = (fst xy) + (snd xy)
Now we have a int * int -> int
. Another single argument function. For anyone unfamiliar, the int * int
syntax is how you represent a tuple in F#. In C#, we use (int, int)
. In F#, int * int
.
Since all functions in F# take just one argument, 'a -> 'b
can represent every function.
Arity is not an issue in F#.
Void Functions
If you're not familiar with F#, you might be wondering about functions that use void
. In C#, void
is not a type, so if you want to use Func<A, B>
to represent a function that either has no arguments, or returns void
, you can't.
In C#, if you want a function that has no arguments and returns a value, you have to use the signature Func<A>
. If you want a function that returns void
, you have to use an Action
instead of a Func
.
F# doesn't have this problem because void
doesn't exist. F# uses a type called unit
to represent "nothing". Since unit
is an actual type, it can take the place of 'a
and/or 'b
in 'a -> 'b
. In F#, when you pass unit
to a function as an argument, it looks like this: ()
. Very similar to calling a C# method with no arguments. For example, if we had a random number generator called rand
, with the signature unit -> int
, we'd call it like this: rand ()
.
Similarly, a function with a signature like string -> unit
, a function that would "return" void
in many languages, works just as well. We'd call the function with a string, and we'd get ()
back from it.
void
is not an issue in F#
F# may not have type-classes. It may not have higher-kinded types. But there's something to be said for consistency. You can build on top of consistency. It is a beautiful thing. And F#'s type system is nothing if not consistent