Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

typescript

Individual pairs may be of different types, but each pair should have two values of the same type. As in [["foo", "bar"], [1, 2]] is valid, but [["foo", 2]] is not. So [any, any][] is too broad.

It is a bit like I want to instantiate a type like type X<T> = [T, T] for each element, with a different T for each. (X<any>[] is, again, too broad).

Is this possible?

(This is a simplification of an issue where the elements are instances of a generic interface, that will often be written as literals, and I'd really like TypeScript's help in catching type mismatches within individual objects.)

like image 961
Marijn Avatar asked Oct 04 '19 12:10

Marijn


People also ask

How do you define a type of array in TypeScript?

An array in TypeScript can contain elements of different data types using a generic array type syntax, as shown below. let values: (string | number)[] = ['Apple', 2, 'Orange', 3, 4, 'Banana']; // or let values: Array<string | number> = ['Apple', 2, 'Orange', 3, 4, 'Banana'];

How do you declare an array of objects in TypeScript interface?

TypeScript Arrays are themselves a data type just like a string, Boolean, and number, we know that there are a lot of ways to declare the arrays in TypeScript. One of which is Array of Objects, in TypeScript, the user can define an array of objects by placing brackets after the interface.

How do I create a pair in TypeScript?

Use an index signature to define a key-value pair in TypeScript, e.g. const employee: { [key: string]: string | number } = {} . An index signature is used when we don't know all the names of a type's keys ahead of time, but we know the shape of their values.


1 Answers

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

like image 119
jcalz Avatar answered Nov 14 '22 21:11

jcalz