0

Say I have function foo(args) {...} where args is an array of 2-tuples such that the entries within the tuple are the same type (i.e. [T,T]), but the entries across tuples may vary arbitrarily (i.e. [[T,T],[U,U],[V,V]]). For example:

foo([
  [1, 3],
  ["hello", "world"],
  [true, true],
  [2, 7]
]) // no error

How should I type the args parameter of foo so that mismatching types within a tuples raises a compile-time type error? For example:

foo([
  [1, 3],
  ["hello", 5], // type error here
  [true, true],
  [2, 7n] // type error here
])

If it's not possible to show the type error inline, making the whole function call error is also acceptable.


Addendum: Can this be made to work with 2-tuples of type [SomeType<T>, T] (i.e the second entry's type should match the generic of the first), but T can still vary between tuples [[SomeType<T>, T],[SomeType<U>, U],[SomeType<V>, V]]?

foo([
  [{value: 1}, 3],
  [{value: "hello"}, 5], // type error here
  [{value: true}, true],
  [{value: 2}, 7n] // type error here
])
Dennis Kats
  • 2,085
  • 1
  • 7
  • 16
  • If i understand correctly, you want each array inside the 2d array to be an array of only one type ? Can they have more than two elements ? – Florent M. Jul 13 '23 at 07:47
  • Yep, the entries of each inner array are the same type, but across the inner arrays they may be different types. In my particular use case, each inner array only has two elements, but a good solution should probably work similarly with any size. To clarify further though, this question is not necessarily about coming up with a good readable type, but it is about making TypeScript give a compilation error when the row entries don’t match type-wise – Dennis Kats Jul 13 '23 at 08:01
  • 1
    Does [this approach](https://tsplay.dev/w86d9m) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame Jul 13 '23 at 10:48
  • @wonderflame I think that approach nearly answers my question exactly! However, in hindsight, my question seems to be only adjacent to a harder problem that I'm actually facing. Basically, instead of each inner array being of type [T, T], I want it to be more like [SomeType, T], i.e the second entry's type should match the generic of the first, and T can still vary between rows. I used your approach as [a starting point](https://tsplay.dev/mpAGaW), but I can't quite get it to work. My apologies if my initial question was misleading, but do you think you find a solution to this? – Dennis Kats Jul 13 '23 at 16:02
  • 1
    I'm on mobile now. Will take a look in a few hours – wonderflame Jul 13 '23 at 16:14
  • 1
    Does [this one](https://tsplay.dev/WK3ZoW) work? – wonderflame Jul 13 '23 at 18:08
  • @wonderflame That seems to work very well, especially in [TypeScript 5.2.0-beta](https://tsplay.dev/NBV6gN), the error message is even inlined! It would be ideal if `as const` wasn't required, but I'm willing to accept your solution as is if you think it's unavoidable and you write up your answer. – Dennis Kats Jul 13 '23 at 18:33
  • Unfortunately, in this specific case, it won't be possible to remove the `as const`... I will explain why in my answer – wonderflame Jul 13 '23 at 18:39
  • 1
    This [would require existential types](https://stackoverflow.com/questions/65129070/defining-an-array-of-differing-generic-types-in-typescript) as far as I can see – Bergi Jul 13 '23 at 19:18

2 Answers2

1

I think you can simply achieve this by creating a type for a row which will accept the array of either string, number or boolean.

type Row = string[] | boolean[] | number[]

And now, we can just assign this type for args parameter for foo function.

function foo(args: Row[]): void {
 ...
 ...
 ...
}

With this type definition, if you will provide an argument to foo where the types of elements with in a row did not match, Typescript will raise an error.

Here is the playground link.

Debug Diva
  • 26,058
  • 13
  • 70
  • 123
  • 1
    It seems ike the only good solution, but typing Row with `(string | boolean | number)[]` won't help, it must be `string[] | boolean[] | number[]`, else an array like `['text', 4]` will be allowed – Florent M. Jul 13 '23 at 08:47
  • @FlorentM. Thanks for pointing that out. I updated the answer with correct `type`. – Debug Diva Jul 13 '23 at 11:29
  • This is close to the type of error I want to see, but it isn't quite what I'm looking for because I don't know the individual `Row` types of `args` ahead of time. @wonderflame seems to be closer to what I want. – Dennis Kats Jul 13 '23 at 15:46
1

To achieve this we will need to use generics for the array and mapped types to map through the elements of the array. Since we know that the array should be an array of tuples of length two, we are going to infer the generic parameter of the first item in the tuple and make the second one of the same type. To get the type of the generic parameter, we need to use the infer keyword. Note that we need to know exactly (or at least the one that has a similar shape) which generic type is used to make it work, which is Variable in our case:

const foo = <T extends unknown[][]>(arr: {
  [K in keyof T]: T[K] extends unknown[]
    ? T[K][0] extends Variable<infer Type>
      ? [Variable<Type>, Type]
      : T[K]
    : T[K];
  }) => {}

It may look like it is all, however let's see the type of the following array:

const arr = [1, '2', false];
// (string | number | boolean)[]
type Arr = typeof arr;

As you can see, the type is not exactly what we have in the arr. The compiler widens the type to make sure that we can mutate the array elements. To let the compiler know that the array is read-only we will need to use const assertion:

const arr = [1, '2', false] as const;
// readonly [1, "2", false]
type Arr = typeof arr;

Looks good, now, this means that we will need to make the array that we pass to the foo read-only` and since read-only arrays are the supersets of mutable arrays we will get an error if we try to pass a read-only array to just array:

// false
type Case1 = readonly number[] extends number[] ? true : false;
// true
type Case2 = number[] extends readonly number[] ? true : false;

Thus, let's update all array types in the foo to read-only. Note that since our array is two-dimensional, the inner arrays will be also read-only and the constraint for the array should be a read-only array of read-only arrays:

const foo = <T extends readonly (readonly unknown[])[]>(arr: {
  [K in keyof T]: T[K] extends readonly unknown[]
    ? T[K][0] extends Variable<infer Type>
      ? readonly [Variable<Type>, Type]
      : T[K]
    : T[K];
}) => {};

Testing:

declare const ctx1: Variable<number>;
declare const ctx2: Variable<string>;
declare const ctx3: Variable<boolean>;
declare const ctx4: Variable<number>;
declare const ctx5: Variable<number[]>;
declare const ctx6: Variable<{ name: string; age: number }>;

foo([
  [ctx1, 3],
  [ctx2, 'world'],
  [ctx3, true],
  [ctx4, 7],
] as const);

foo([
  [ctx1, 3],
  [ctx2, 'world'],
  [ctx3, true],
  [ctx4, 'invalid'], // error
] as const);

However, we still have some problems. For example, if the first element in the tuple is Variable<7> it will mean that the second argument should be also 7, not any number, and if that's an issue we need to get the primitve of the 7 which is number. This can be achieved using ToPrimitive utility type from my type-samurai open-source project:

type ToPrimitive<T> = T extends string
  ? string
  : T extends number
  ? number
  : T extends null
  ? null
  : T extends undefined
  ? undefined
  : T extends boolean
  ? boolean
  : T extends bigint
  ? bigint
  : T extends symbol
  ? symbol
  : {
      [K in keyof T]: ToPrimitive<T[K]>;
    };

Updated function:

const foo = <T extends readonly (readonly unknown[])[]>(arr: {
  [K in keyof T]: T[K] extends readonly unknown[]
    ? T[K][0] extends Variable<infer Type>
      ? ToPrimitive<Type> extends infer PrimitiveType
        ? readonly [Variable<PrimitiveType>, PrimitiveType]
        : T[K]
      : T[K]
    : T[K];
}) => {};

Another issue is if the inferred type is number[] in our current foo implementation we won't let the read-only arrays:

foo([
  [ctx5, [4, 5, 6]], // The type 'readonly [4, 5, 6]' is 'readonly' and cannot be assigned to the mutable type 'number[]'
] as const)

The fix is pretty straightforward, we will check whether the inferred type is some array then we will get its elements type and write readonly ElemenType[] as the second argument in the tuples:

const foo = <T extends readonly (readonly unknown[])[]>(arr: {
  [K in keyof T]: T[K] extends readonly unknown[]
    ? T[K][0] extends Variable<infer Type>
      ? ToPrimitive<Type> extends infer PrimitiveType
        ? readonly [
            Variable<PrimitiveType>,
            PrimitiveType extends Array<infer ArrayItem>
              ? readonly ArrayItem[]
              : PrimitiveType,
          ]
        : T[K]
      : T[K]
    : T[K];
}) => {};

Testing:

foo([
  [ctx1, 3],
  [ctx2, 'world'],
  [ctx3, true],
  [ctx4, 7],
  [ctx5, [4, 5, 6]],
  [ctx6, {name: "Hi", age: 23}],
] as const);

foo([
  [ctx1, 3],
  [ctx2, 'world'],
  [ctx3, true],
  [ctx4, true], // error here
  [ctx5, [4, 5, 6]],
  [ctx6, 50], // error here
] as const);

The annoying part is that we need to use const assertion everywhere. In the Typescript 5.0 the const type parameters were added, which let's avoid const assertions:

const foo = <const T extends readonly unknown[]>(item: T) => item
// readonly [1, 2, 3] 
const result = foo([1,2,3])

Unfortunately, we are not able to use them, since we do some manipulation with the argument instead of directly assigning T as a type to it:

const foo = <const T extends readonly unknown[]>(item: {[K in keyof T]: T[K]}) => item

// const result: (2 | 1 | 3)[]
const result = foo([1, 2, 3])

In conclusion, for now, the const assertion is the only way to make sure that it works as expected.

Link to playground

wonderflame
  • 6,759
  • 1
  • 1
  • 17