TypeScript: Require that two arrays be the same length?

Viewed 2464

Say I'm making a Tab + Panel component called TabsPanels. I want to ensure that I'm getting the same number of Tab and Panel components, like so:

type TabsPanelsProps = {
  tabs: Tab[];
  panels: Panel[];
}
<TabsPanels
  tabs={[<Tab/>, <Tab/>]}
  panels={[<Panel/>]} // Error: tabs.length and panels.length do not match
/>

Is there any way to do this? If there was some utility function like

PropsAreEqual<T, K1, K2, P>
where
T = type
K1 = key 1
K2 = key 2
P = the property to be equal

That's obviously bad, but you get what I'm saying. Then you could do

PropsAreEqual<TabsPanelsProps, 'tabs', 'panels', 'length'>
4 Answers

I see a few answers with questions around inferring the length of the array literals. The issue is that when you pass in an array literal to a function, the compiler generally widens it to an array and does not interpret it as a fixed-length tuple. This is often what you want; often arrays change length. When you want the compiler to see [1, 2] as a pair and not as an array, you can give the compiler a hint:

function requireTwoSameLengthArrays<
    T extends readonly [] | readonly any[]
>(t: T, u: { [K in keyof T]: any }): void { }

Notice that the generic type parameter T's generic constraint is a union of an empty tuple type [] and an array type any[]. (Don't worry about readonly; this modifier makes the function more general, not more specific, since string[] is assignable to readonly string[] and not vice versa.) Having the empty tuple type in a union doesn't change the kinds of things that T can be (after all, any[] already includes the empty tuple []). But it does give the compiler a hint that tuple types are desired.

So the compiler will infer [1, 2] as [number, number] instead of as number[].


Examining the signature above, you see that the u argument is a mapped array/tuple type. If T is an tuple, {[K in keyof T]: any} is a tuple of the same length as T.

So let's see it in action:

requireTwoSameLengthArrays([1, 2], [3, 4]); // okay
requireTwoSameLengthArrays([1, 2], [3]); // error! property 1 is missing in [number]!
requireTwoSameLengthArrays([1, 2], [3, 4, 5]); // error! length is incompatible!

Hooray!


Note that if the compiler has already forgotten the length of the tuple, this will not work:

const oops = [1, 2]; // number[]
requireTwoSameLengthArrays(oops, [1, 2, 3]); // okay because both are of unknown length

The type of oops is inferred as number[], and passing it into requireTwoSameLengthArrays() can't undo that inference. It's too late. If you want the compiler to just reject arrays of completely unknown length, you can do it:

function requireTwoSameLengthTuples<
    T extends (readonly [] | readonly any[]) & (
        number extends T["length"] ? readonly [] : unknown
    )>(t: T, u: { [K in keyof T]: any }): void { }

This is uglier, but what it's doing is checking to see if T has a length of number instead of some specific numeric literal. If so, it prevents the match by demanding an empty tuple. This is a little weird, but it works:

requireTwoSameLengthTuples([1, 2], [3, 4]); // okay
requireTwoSameLengthTuples([1, 2], [3]); // error! [number] not [any, any]
requireTwoSameLengthTuples([1, 2], [3, 4, 5]); // error! ]number, number, number]

requireTwoSameLengthTuples(oops, [1, 2, 3]); // error on oops!
// Types of property 'length' are incompatible.

Okay, hope that helps; good luck!

Playground link to code

This is possible by requiring the consumer to pass it a length generic type to the function

function same<T extends number>(
  nums: (readonly number[] & { readonly length: T }),
  strings: (readonly string[] & { readonly length: T })
) { }

same<2>(
  [3, 4] as const,
  ['1', '4'] as const
)

The only limitations are that you need to pass in the <2> or else typescript is kind enough to infer the generic number for N, also you need to declare all arguments as const to have them lose the tuple length through type erasure

To get it working with in a react render function you'd need to do some additional some ugly TypeScript conditional types

Here's a partial answer for you:

type ArrayOfFixedLength<T extends any, N extends number> = readonly T[] & { length: N }; 

const a1: ArrayOfFixedLength<number, 2> = [1] as const; 
const a2: ArrayOfFixedLength<number, 2> = [1, 2] as const; 


function myFunction<N extends number>(array1: ArrayOfFixedLength<any, N >, array2: ArrayOfFixedLength<any, N>) {
return true; 
}

myFunction<3>([1, 2, 3] as const, [2, 3, 4] as const); 
myFunction<2>([1, 2] as const, [1, 2, 3] as const);

// However, if you don't specify the array length, 
// It fails to error
myFunction([1, 2, 3] as const, [2, 3, 4] as const); 
myFunction([1, 2] as const, [1, 2, 3] as const);

playground

Explanation

Because we are essentially using the length property of the array to check that they're equal, the array needs to be treated as immutable, hence the use of the readonly and as const keywords.

What we do is declare that we have an array, and that array always have length N.

Now, what I don't understand about this code, is why the generic argument in myFunction<3> is required. I don't know why the value of N isn't being inferred by arguments passed in.

Now, in your case, you might be better of just checking the array lengths at runtime and throwing an error. Remember that typescript is for developer experience, typically in catching in errors before they occur, but in this instance, doing a runtime error might be the quickest and easiest way to catch this kind of error.

Iterating on @jcalz answer and with inspiration from @cancerbero, I came upon a slightly different strategy which works well enough in my case:

type Tuple<T> = readonly T[] | readonly [];
type TupleOfLength<T, Length extends number> = Length extends 0 ? readonly [] :
    readonly [T, ...T[]] & { readonly length: Length };

type LengthOfTuple<Tuple extends readonly any[]> = number extends Tuple['length'] ? never : Tuple['length'];

function requireTwoSameLengthTuples<T, FirstArray extends Tuple<T>>(
    firstArray: FirstArray,
    secondArray: TupleOfLength<T, LengthOfTuple<FirstArray>>
) {
    // Do your processing
}

requireTwoSameLengthTuples([1, 2], []); // fails
requireTwoSameLengthTuples([1, 2], [3]); // fails
requireTwoSameLengthTuples([1], [2, 3]); // fails
requireTwoSameLengthTuples([1, 2], [3, 4]); // works

const array1 = [1, 2];
const array2 = [3, 4];
requireTwoSameLengthTuples(array1, array2); // fails too

Typescript playground

Related