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.

Writing a strongly typed flip function in TypeScript
Flip. Photo by Liam Shaw / Unsplash

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:

type Fun<Args extends any[], Result> = (...args: Args) => Result;

type If<
  Predicate extends boolean,
  True = true,
  False = false
> = Predicate extends true ? True : False;

type Extends<Type, Base> = Type extends Base ? true : false;

type And<A extends boolean, B extends boolean> = If<A, B>;

type Not<A extends boolean> = If<A, false, true>;

type Equals<A, B> = And<Extends<A, B>, Extends<B, A>>;
  
export declare function equals<Actual, Expected>(): Equals<
  Actual,
  Expected
>;

export declare function not_equals<Actual, Expected>(): Not<
  Equals<Actual, Expected>
>;

types.test.ts

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?

export declare function assert(...assertions: true[]): void;

assert(equals<1, 1>());
assert(equals<1, 2>()); // Type Error
assert(equals<1, 1>(), equals<"a", "a">());

types.test.ts

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:

type Reverse<Tuple extends any[]> = Tuple extends []
  ? []
  : Tuple extends [...infer Prefix, infer Suffix]
  ? [Suffix, ...Reverse<Prefix>]
  : never;

reverse.type.ts

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:

type N = number;
type S = string;
type B = boolean;

assert(
  equals<Reverse<[]>, []>(),
  equals<Reverse<[N]>, [N]>(),
  equals<Reverse<[N, S]>, [S, N]>(),
  equals<Reverse<[N, S, B]>, [B, S, N]>()
);

reverse.assert.ts

So far so good. We can probably even rewrite flip:

export const flip =
  <Args extends any[], Result>(
    f: Fun<Args, Result>
  ): Fun<Reverse<Args>, Result> =>
  (...args) =>
    f(...(args.reverse() as any as Args));

flip.ts

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:

assert(
  equals<Reverse<number[]>, number[]>(),
  equals<Reverse<[string, ...number[]]>, (string | number)[]>(),
  equals<Reverse<[...string[], number]>, (string | number)[]>()
);

reverse.ts

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:

type IsNever<Type> = [Type] extends [never] ? true : false;

type Equals<A, B> = If<
  And<Not<IsNever<A>>, Not<IsNever<B>>>,
  And<Extends<A, B>, Extends<B, A>>
>;

equals.type.ts

never extends every type, so we need to exclude it from A. Now we can check our expectations of the Equals type:

assert(not_equals<never, 1>());
assert(not_equals<1, never>());
assert(equals<never, never>()); // fail
assert(equals<1, 1>());

equals.type.test.ts

We want to retain the property that never equals exclusively itself, so let's rewrite our Equals type a little:

type IsNever<Type> = [Type] extends [never] ? true : false;

type Equals<A, B> = If<
  IsNever<A>,
  IsNever<B>,
  And<Not<IsNever<B>>, And<Extends<A, B>, Extends<B, A>>>
>;

equals.type.ts

Great, now we're saying that never only ever equals itself, and every other type also only equals itself:

assert(not_equals<never, 1>());
assert(not_equals<1, never>());
assert(equals<never, never>());
assert(equals<1, 1>());

equals.type.test.ts

Now that we don't equate never with any type, our expectations on Reverse<number[]> and the like should also fail:

// Failures
assert(
  equals<Reverse<number[]>, number[]>(),
  equals<Reverse<[string, ...number[]]>, (string | number)[]>(),
  equals<Reverse<[...string[], number]>, (string | number)[]>()
);

reverse.type.test.ts

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:

assert(
  equals<Reverse<number[]>, never>(),
  equals<Reverse<[string, ...number[]]>, never>(),
  equals<Reverse<[...string[], number]>, never>()
);

reverse.type.test.ts

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:

const repeat = <A>(a: A, count = 1): A[] =>
  new Array(count).fill(a);

const flipped_repeat = flip(repeat);

repeat.ts

You will notice that flipped_repeat can never be called!

flipped_repeat();        // Error
flipped_repeat("a");     // Error
flipped_repeat(2);       // Error
flipped_repeat(2, "a");  // Error

repeat.test.ts

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:

assert(equals<Reverse<[1, 2, 3?]>, [3 | undefined, 2, 1]>());

reverse.type.test.ts

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:

assert(
  equals<Reverse<[1, 2, 3 | undefined]>, [3 | undefined, 2, 1]>()
);

reverse.type.test.ts

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:

export type Explicit<Args extends any[]> = // Not implemented

assert(
  equals<Explicit<[S]>, [S]>(),
  equals<[S, N?], [S, N?]>(),
  equals<Explicit<[S, N?]>, [S, N | undefined]>(),
  not_equals<Explicit<[S, N?]>, [S, N?]>()
);

explicit.type.ts

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:

export type Replace<Original, Patch> = {
  [Key in keyof Original]: Key extends keyof Patch
    ? Patch[Key]
    : Original[Key];
};

assert(
  equals<Replace<{ a: 1 }, { a: 2; b: 1 }>, { a: 2 }>(),
  equals<Replace<[0, 1, 2], [0, 1, 4, 5]>, [0, 1, 4]>(),
  equals<Replace<[0, 1, 2, 3], [0, 1, 2, 3]>, [0, 1, 2, 3]>()
);

replace.type.ts

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:

export type Explicit<Args extends any[]> = Replace<
  Required<Args>,
  Args
>;

assert(
  equals<Explicit<[S]>, [S]>(),
  equals<[S, N?], [S, N?]>(),
  equals<Explicit<[S, N?]>, [S, N | undefined]>(),
  not_equals<Explicit<[S, N?]>, [S, N?]>()
);

explict.type.ts

Now, all our tests should be passing. Let's create a specific Flip type that uses this enhancement, and then reverses the args:

export type Flip<Args extends any[]> = Reverse<Explicit<Args>>;

assert(
  equals<Flip<[]>, []>(),
  equals<Flip<[N]>, [N]>(),
  equals<Flip<[N, S]>, [S, N]>(),
  equals<Flip<[N, S, B]>, [B, S, N]>(),
  equals<Flip<N[]>, never>(),
  equals<Flip<[S, ...N[]]>, never>(),
  equals<Flip<[...S[], N]>, never>(),
  equals<Flip<[1, 2, 3 | undefined]>, [3 | undefined, 2, 1]>(),
  equals<Flip<[1, 2, 3?]>, [3 | undefined, 2, 1]>()
);

flip.type.ts

Now, all our tests should be passing on Flip, and we can use Flip instead of Reverse in our definition of flip:

export const flip =
  <Args extends any[], Result>(
    f: Fun<Args, Result>
  ): Fun<Flip<Args>, Result> =>
  (...args) =>
    f(...(args.reverse() as any as Args));

flip.ts

Now, to test with our implementation with an optional argument:

const repeat = <A>(a: A, count = 1): A[] =>
  new Array(count).fill(a);

const flipped_repeat = flip(repeat);
const result = flipped_repeat(2, "a");

assert(equals<typeof result, string[]>());

repeat.ts

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:

const flipped_repeat: <A>(
  args_0: number | undefined,
  args_1: A
) => A[];

flipped-repeat.type.ts

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:

type ReverseArgs<Args extends any[]> = Args extends []
  ? []
  : Args extends [...infer Prefix, unknown]
    ? Args extends [...Prefix, ...infer Suffix]
      ? [...Suffix, ...ReverseArgs<Prefix>]
      : never
    : never;

export type FlipArgs<Args extends any[]> =
  ReverseArgs<Explicit<Args>>;

export const flip =
  <Args extends any[], Result>(
    f: Fun<Args, Result>
  ): Fun<FlipArgs<Args>, Result> =>
  (...args) =>
    f(...(args.reverse() as any as Args));

const flipped_repeat = flip(repeat);
const result = flipped_repeat(2, "a");

assert(equals<typeof result, string[]>());

flipped-repeat.ts

Now, we can see that the names of the arguments have been retained:

const flipped_repeat: <string>(
  count: number | undefined,
  a: string
) => string[];

flipped-repeat.type.ts

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!

Subscribe to The ArtfulDev Journal

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe