Writing a strongly typed flip function in TypeScript
I've talked about flip functions before. At the request of a reader, it's time to explore a more robust, strongly typed version. Let's dive in.
A few days ago, I had written a few posts in which I described a flip. In the first I used a multivariate version in JavaScript, and in the second, when I used TypeScript, I wrote a simple flip
function that only works on functions with 2 arguments (as is idiomatic). However one of my readers pointed this out, asking why I didn't take the opportunity to write the multi-variate version in the post with TypeScript. Which is a really good question. Well the fact is that it's really much easier to write a multivariate version in JavaScript, and a binary (with 2 arguments) version in TypeScript. Given the interest, however, I'm compelled to share how we might write a multi-variate version of flip
in TypeScript, of course with strong and accurate typing as a goal.
NOTE: If you're interested in advanced types in TypeScript, the source of ts-toolbelt
is an excellent place to start, along with its splendid blog post, both of which I highly recommend. It may be a little outdated but is a strong start to fundamentals.
It's highly encouraged to follow along with an editor, or you can use TS Playground.
Testing types
Well when we're trying to come up with types, it's good to have a way to test types. Let's setup a simple way to test our types, if they do what we expect. We want to use the TypeScript compiler to help us in our testing. We'll write some code that if the TypeScript compiler doesn't complain about, we can rest assured that we're on the right track.
Fresh from our earlier post on predicates, let's write some types that help:
Here we've created a type Equals<A, B>
which returns whether a type A
is equal to another type B
or not. And we've written a function declaration for equals
which basically takes 2 types, and returns whether they're equal. We also defined the complementary not_equals
. How do we create a way to assert?
Now that we have a basic way to do type assertions, let's attempt a strongly typed flip
.
A simple flip
Well, what does flip do other than reversing the function arguments? Not much, so this should indeed be simple. We just have to get the arguments array, and reverse it, creating a new function signature with those reversed arguments. Let's do that:
Reversing a tuple (which is just a heterogenous array of a fixed size) seems simple enough! We basically check if the type is an empty array, and if it is, we simply also return an empty array (we can also return Tuple
itself). If not empty, we then infer
the prefix type (which will be a tuple type) and the type of the last element in the tuple, and reconstruct the reversed type recursively. infer
is another useful helper in addition to extends
and can only be used in an extends
clause. It makes TypeScript infer the type from usage and assign it to the name.
Here, we say that Tuple
(which is an array), is an extension of another array Prefix
(we use the spread operator ...
to collect the items in the tuple/array into a single name) with the last element Suffix
. So if the type of Tuple
can match this, both Prefix
and Suffix
will be inferred. Given we already ensured that the tuple is not empty, Suffix
will always have a type, and Prefix
can be an empty or non-empty Tuple. If the type inference succeeds, we can return the reversed tuple by putting the suffix first and then returning the reverse of the prefix.
Now that we have a simple type definition, let's put it to the test:
So far so good. We can probably even rewrite flip:
Note that while we're using args.reverse()
which mutates the array, it's okay for our purposes as we don't do much with it other than immediately pass it in to the original function. We also use some type-casting here to overcome TypeScript's inability to see that args.reverse()
returns Reverse<Reverse<A>>
which is the same type as A
.
All the code we wrote for the leap
example in the previous post still works with our latest changes to flip
(which powers pipe
), so we should be good.
Additional considerations
There are some cases where this simple implementation of our type might not be enough. Let's examine some of these cases in more detail.
Variadic tuples (and arrays)
Variadic tuples are those which have spread arguments, or a variable number of values of the same type. For example, [number, ...string[]]
. These kinds of tuples don't work well with our prefix spread inference. It can also probably be seen that plain generic arrays are also similar to this. For example string[]
is also kind of [...string[]]
. Let's try to work out the types to the best of our ability.
It's best to start with a simple assertion:
The assertion passes? Well, let's see why. With our current definition of Reverse
, it evaluates Reverse<number[]>
to never
, and Equals<never, number[]>
evaluates to true
. Let's fix that:
never
extends every type, so we need to exclude it from A. Now we can check our expectations of the Equals type:
We want to retain the property that never
equals exclusively itself, so let's rewrite our Equals
type a little:
Great, now we're saying that never
only ever equals itself, and every other type also only equals itself:
Now that we don't equate never
with any type, our expectations on Reverse<number[]>
and the like should also fail:
We don't want to fix this.
You read that right. Let's think about what fixing it might end up causing. If we can infer the generic type of a list and use it to infer the type of the reversal, we might no longer be able to differentiate between [number]
and number[]
as they are both extend number[]
. No, instead we'll be okay with this to retain type safety in tuples. Otherwise, [number, string, boolean]
will be reduced to (number | string | boolean)[]
which we don't want!
Let's fix our expectations instead:
That makes our types and assertions more robust.
Optional arguments
You may also notice that the present types don't work well with optional arguments. We can take an example in a while, but it stands to reason that optional arguments are only allowed at the end of the list of arguments, but when we reverse that, it is no longer a valid list of arguments to a function:
You will notice that flipped_repeat
can never be called!
This is because our present way to reverse the list doesn't consider optional arguments. Let's attempt to handle this though - as this is pretty much required for function arguments. Let's create some expectations as usual:
We should see the last assertion failing. But that's expected. Let's see if we can make this work. Firstly, we want to be able to unwrap the 3?
type into a 3 | undefined
- if we can manage that, it's a simple case of reversing the list:
We can see that this reversal already works as expected. So let's unwrap the type inside the tuple so that we can support optional arguments. We can start with our expectations, naming our type Explicit
:
Now how can we implement it? If we can make all properties required in the tuple type, then the definition { [k]?: T }
becomes expanded into { [k]: T | undefined }
. Let's use that. First, we can create a simple Replace
type:
Now, our explicit type is just a replacement of the actual type on the required type - since the actual type will be missing some keys which it will not override:
Now, all our tests should be passing. Let's create a specific Flip
type that uses this enhancement, and then reverses the args:
Now, all our tests should be passing on Flip
, and we can use Flip
instead of Reverse
in our definition of flip
:
Now, to test with our implementation with an optional argument:
Retaining argument names
Until now we've been working with types of the arguments for the flip
function, but functions have one more useful part: the names of the arguments. Unfortunately, we can't write test cases with types for that part, and have to rely on the inference of the language service. If we look at the type declaration (inferred) for our latest flipped_repeat
, we'll see that it's:
We can see this type on hovering over the flipped_repeat
function in an editor with the TS language service attached, or the TS playground, or if we start calling the function.
Notice that the names of the arguments are now missing. We can work those in, by rewriting how we reversed the arguments, to infer not the last element as we did before, but by inferring the prefix, and then the tail end of the tuple (instead of the last element) and the tuple retains the name:
Now, we can see that the names of the arguments have been retained:
What's idiomatic?
Flip is generally a name for a function that's used to flip arguments to a function that takes 2 arguments - so that the head and tail are swapped. I tossed and turned this in my head if it's idiomatic or not to call a function which reverses all the arguments to a function flip
, but I had some justification. While it still works as a flip, consider this extended/expanded functionality. It also served a fun factor for us to explore some advanced typing with TypeScript, so I hope it's admissible.
Conclusion
Finally! Everything has come together. And no one can blame me for taking the easy way out and not defining a proper strongly typed version of flip
anymore, although it doesn't work with variadic function arguments.
That was quite a lot we went through, hopefully it was as exciting for you to follow as it was for me to write it. It's also nice to see how powerful TypeScript is and how expressive it is (even if it has limitations). Hope we use types where it makes sense to better describe our intent!