Can the type 'an array of pairs of values of the same type' be expressed in TypeScript?

This is the kind of thing that usually needs to be represented as a constrained generic type and not a concrete type. And therefore anything that wants to deal with such a type will need to also deal with generics. Often that means you want a helper function to verify that some value matches the type. Here's how I'd do this one:

type SwapPair<T> = T extends readonly [any, any] ? readonly [T[1], T[0]] : never;

type AsArrayOfPairs<T> = ReadonlyArray<readonly [any, any]> &
    { [K in keyof T]: SwapPair<T[K]> }

const asArrayOfPairs = <T extends AsArrayOfPairs<T>>(pairArray: T) => pairArray;

The type SwapPair<T> takes a pair type like [A, B] and turns it into [B, A] (the readonly bit just makes it more general; you can remove those if you want. In all of the above you can make readonly things mutable and it will work, and might be easier to see what's happening)

And AsArrayOfPairs<T> takes a candidate type T, and swaps all the pair-like properties around. This produces a new type that should be equal to T if it's a valid array of pairs. Otherwise it will be different.

For example, AsArrayOfPairs<[[number, number]]> will produce readonly (readonly [any, any])[] & [readonly [number, number]]. Since [[number, number]] is assignable to that, it's a success. But AsArrayOfPairs<[[string, number]]> produces readonly (readonly [any, any])[] & [readonly [number, string]], to which [[string, number]] is not assignable. Because [string, number] and [number, string] are not compatible.

And the helper function asArrayOfPairs will validate a value without widening it. Let's see it work:

const goodVal = asArrayOfPairs([[1, 2], ["a", "b"], [true, false]]); // okay
// const goodVal: ([number, number] | [string, string] | [boolean, boolean])[]

This compiles fine, and the type of goodVal is inferred as Array<[number, number] | [string, string] | [boolean, boolean]>. And then this:

const badVal = asArrayOfPairs([[1, 2], ["a", "b"], [true, false], ["", 1]]); // error!
// error! (string | number)[] is not assignable  ---------------> ~~~~~~~

gives an error, complaining about the ["", 1] entry. As it tries and fails to infer that entry, the compiler widens it to (string | number)[] and then finally gives up saying that it doesn't seem to be a valid pair type. It's not the error message I'd choose, but it shows up in the right place, which is good.


There are other ways to approach this problem, but most of the simpler things I tried didn't work. For example, you could do a mapped tuple inference like this:

const simplerButTooWide = <T extends readonly any[]>(t: [] | { [K in keyof T]: [T[K], T[K]] }) => t;
simplerButTooWide([[1, ""], [true, undefined]]); // no error here!
// [[string | number, string | number], [boolean | undefined, boolean | undefined]]

But the compiler is happy to look at a value of type [1, ""] and infer it as a pair of type [string | number, string | number]. And so just about anything will be a "valid" pair, if the inference widens enough. Preventing that widening requires some tricks like the above array swap, which happens after the type is already inferred. So there's a bit of an art here as opposed to a science.


Anyway, hope that helps. Good luck!

Link to code

Tags:

Typescript