Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can repeated tuples be defined in Typescript

I recently encountered an API with arrays structured with an arbitrary number of repeated tuples. I tried a few alternatives to achieve it but hit problems with types having infinite recursion or just not expressing the constraint.

How would I be able to type RepeatedTuple as per the case below?

//Possibly a recursive conditional way to define RepeatedTuple?
// https://github.com/microsoft/TypeScript/pull/40002

type Example = RepeatedTuple<[string,number]>;

const example0:Example = [];
const example1:Example = ["hello",1];
const example2:Example = ["hello",1,"hello",2];
const example3:Example = ["hello",1,"hello",2,"hello",3];
const example4:Example = ["hello",1,"hello",2,"hello",3,"hello",4];

There is a Typescript playground at https://www.typescriptlang.org/play?ts=4.2.0-beta&ssl=1&ssc=1&pln=10&pc=68#code/PTAKHsGdISwIwDYE8AEBDFAnApgYwK6awBu2Ku4AdgCYwAuMVaCKA7mqneCtdgGYxKZAErYADtjR1s1ACr4xCbAH4AUCBQALOnTGQAXCADm9TfjgA6CgFtg1mLkxRwfOsFlIJAZUcwxbsXwEBGAAFgAGSIAmVVU6TzIAUQAPNGtFMgBeFFEJKRl5DIAeAG1IOkxBIwAaSnxrOGxMAF0APgBuWIpKcpRsVPSlcP0UtIyUbJLmzu7e-rGlAEYRgfHJgCJNbGDwderF6dVZuj7VpSiVhayUEs3thF396rudvajD49OrgGZLwevbltXk8Xg83s8gWDqt8PlQ5mdsKE-msbqDHosIfdHlFMcDvrioaFpkA which has the above case.

like image 825
cefn Avatar asked Nov 07 '22 02:11

cefn


1 Answers

There isn't any specific type in TypeScript that represents "a tuple consisting of an arbitrary number of copies of string, number". In cases like this, the closest you can get is either some finite union that covers the use cases you actually anticipate, or some self-referential generic constraint which you can use to validate a candidate tuple.

Note that while the latter seems like it would allow for completely arbitrary length tuples, the inference support for recursion is fragile and can scale badly depending on the version of TypeScript. Because the finite union scales linearly with the length of the tuple, you will actually get farther with it than with recursion.


Here is one way to write RepeatedTuple for lenghts of up to, say, 40 repeats:

type _R<T extends readonly any[], U extends readonly any[]> = 
  readonly [] | readonly [...U, ...T];
type RepeatedTuple<T extends readonly any[]> =
    _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T,
        _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T,
            _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T, _R<T,
                _R<T, _R<T, _R<T, _R<T, readonly []>>>>>
            >>>>>>>>>>>>>>>>>>>
        >>>>>>>>>>>>>
    >>>;

This uses variadic tuples to concatenate arrays, and we just manually compose a union-or-concatenate operation 40 times. You can add to this if you need to, although eventually this would reach some limit. Let's see how it works:

type Example = RepeatedTuple<[string, number]>
const example0: Example = [];
const example1: Example = ["hello", 1];
const example2: Example = ["hello", 1, "hello", 2];
const example3: Example = ["hello", 1, "hello", 2, "hello", 3];
const example4: Example = ["hello", 1, "hello", 2, "hello", 3, "hello", 4];

const badExample0: Example = ["hello"] // error!
// Source has 1 element(s) but target requires 80.
const badExample1: Example = ["hello", "goodbye"]; // error!
// Type 'string' is not assignable to type 'number | undefined'.
const badExample2: Example = ["hello", 1, "hello"]; // error!
// Source has 3 element(s) but target requires 80.
const badExample3: Example = ["hello", 1, "hello", 2, false]; // error!
// Type 'false' is not assignable to type 'string | undefined'.
const badExample4: Example = ["hello", 1, "hello", 2, "hello"]; // error!
// Source has 5 element(s) but target requires 80.


const limits: Example = ["", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0,
    "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0,
    "", 0, "", 0, "", 0, "", 0, "", 0, "", 0]; // okay

This looks good. Even the relatively long limits example works. Some of the error messages for the failure cases are a bit weird, since the compiler focuses on the tuple-of-length-80 in its explanation for why you made a mistake. But it works as expected. If you made a tuple of length 82 or greater this would fail, though.


For completeness, let's explore the generic constraint approach:

type ValidRepeatedTuple<T extends readonly any[], U extends readonly any[]> =
    U extends readonly [] ? U :
    readonly [...T, ...(U extends readonly [...T, ...infer R] ? ValidRepeatedTuple<T, R> : [])];

const asValidRepeatedTuple = <T extends readonly any[]>() =>
    <U extends readonly any[]>(u: ValidRepeatedTuple<T, U>) => u;

This is using a recursive conditional type to check a candidate tuple U against a concatenation of the tuple-to-repeat T. If U is a valid repetition of T, then U will extend ValidRepeatedTuple<T, U>. Otherwise, ValidRepeatedTuple<T, U> will a "nearby" valid tuple so that the error message will give a more reasonable hint about what to do to fix the error.

Notice that adding generics means that generic type parameters need to be specified. To relieve developers from having to do this completely manually, I've added the helper function asValidRepeatedTuple() so that U can be inferred. Furthermore, since you want to manually specify T, we need to make asValidRepeatedTuple() a curried function because there is currently no way to have partial type parameter inference of the sort requested in microsoft/TypeScript#26242; in a single generic function call with type parameters T and U, you either need to manually specify both, or let the compiler infer both. The currying lets you manually specify T while having the compiler infer U, at the cost of having an extra function call in there.

So, that's a bit of complication... does it work? Let's see:

const asExample = asValidRepeatedTuple<[string, number]>();

const example0 = asExample([]);
const example1 = asExample(["hello", 1]);
const example2 = asExample(["hello", 1, "hello", 2]);
const example3 = asExample(["hello", 1, "hello", 2, "hello", 3]);
const example4 = asExample(["hello", 1, "hello", 2, "hello", 3, "hello", 4]);

const badExample0 = asExample(["hello"]); // error!
// Source has 1 element(s) but target requires 2.
const badExample1 = asExample(["hello", "goodbye"]); // error!
// Type 'string' is not assignable to type 'number'.
const badExample2 = asExample(["hello", 1, "hello"]); // error!
// Source has 3 element(s) but target requires 4.
const badExample3 = asExample(["hello", 1, "hello", 2, false]); // error!
// Type 'boolean' is not assignable to type 'string'.
const badExample4 = asExample(["hello", 1, "hello", 2, "hello"]); // error!
// Source has 5 element(s) but target requires 6.

Yes, looks good. The error messages are nicer than the previous versions, since they mention a more "nearby" tuple than the one the compiler picks from the finite union. (Instead of saying "your length 3 tuple is bad, you should make it 80", it says "you should make it 4.)

But the big downside here is the recursion limit:

const limits = asExample(["", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0,
    "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0, "", 0,
    "", 0, "", 0, "", 0, "", 0, "", 0, "", 0]); // error!
// Type instantiation is excessively deep and possibly infinite.(2589)

A tuple of length 46 is too long because the compiler recurses too much. The finite union, which seems more limited, is actually better at handling longer tuples. You could possibly fix this up by unrolling some of the recursion manually, thus stretching the limit out to something somewhat larger. But it's unlikely to get as far as a manual union.

You might be able to write a less straightforward recursive generic constraint that scales logarithmically with the length of the tuple by doubling-or-not the tuple at each step in the recursion. Look at this GitHub issue comment for a similar technique. This might work for very long tuples, but it would be hard for someone to understand what it's doing (harder even than the stuff already here) and I haven't gotten it to work yet.

Or, you could try to take advantage of the support for tail-recursion elimination for recursive conditional types, but in my attempts I haven't gotten inference to work well and calls to asExample() tend to infer any[] or (string | number)[] where you'd like to see something like [string, number, string, number]. I tried about six different ways and none of them are working for me... so I give up.

Finally, even if you perfected this, be warned that it can only really be used to validate a candidate specific tuple. The compiler won't be able to follow the logic for a generic tuple. So, for example, inside a function that accepts a ValidRepeatedTuple<T, U> for some generic U, the compiler will be hopelessly lost about what that implies:

function hmm<U extends readonly any[]>(t: ValidRepeatedTuple<[string, number], U>) {
    t[0].toUpperCase() // compiler thinks it's a string, but hey, it might be undefined
    t[1].toFixed() // compiler thinks it's a number, but hey, it might be undefined
    t[2]?.toUpperCase() // error! compiler thinks it's undefined, but hey, it might be a string
}

Things are slightly better for the big-old-union case:

function hmm(t: Example) {
    t[0]?.toUpperCase() // okay
    t[1]?.toFixed() // okay
    t[2]?.toUpperCase() // okay
    for (let i = 0; i < t.length / 2; i++) {
        t[2 * i]?.toUpperCase(); // error
        t[2 * i + 1]?.toFixed(); // error
    }
}

although you can see it doesn't really understand the kinds of operations you might be doing, so 🤷‍♂️.


Playground link to code

like image 72
jcalz Avatar answered Nov 12 '22 10:11

jcalz