skip to Main Content

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
])

2

Answers


  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.

    Login or Signup to reply.
  2. 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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search