Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript: Require that two arrays be the same length?

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'>
like image 211
Benny Hinrichs Avatar asked Jun 05 '20 00:06

Benny Hinrichs


2 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

like image 69
jcalz Avatar answered Oct 07 '22 07:10

jcalz


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

like image 2
qwertymk Avatar answered Oct 07 '22 07:10

qwertymk