Search code examples
typescripttuplesundefinedmapped-types

Can a function return a mapped tuple without noUncheckedIndexedAccess errors?


The code example below cannot compile with noUncheckedIndexedAccess. While literalValue is known to be a number, the value and spreadValue variables have 'undefined' in their type union.

Is there any way to annotate the squareTuple and squareSpreadTuple functions, so that passing a tuple of a specific arity will return a result which can be destructured like a literal tuple, without a spurious undefined arriving in its type union?

The Mapped type in the example code was an attempt do do this (by passing on the length attribute of the tuple to its return). It seems to have no effect.

Is there anything else which can be done to transfer the arity of the argument tuple to its returns in a way that destructuring would respect?

function square(num:number){
  return num*num;
}

type Mapped<NumList extends ReadonlyArray<number>> = ReadonlyArray<number> & {length:NumList["length"]}

function squareTuple<NumList extends ReadonlyArray<number>>(nums:NumList): Mapped<NumList> {
  return nums.map(square);
}

function squareSpreadTuple<NumList extends ReadonlyArray<number>>(...numList:NumList) : Mapped<NumList> {
  return squareTuple(numList)
}

const [literalValue] = [9,16,25] as const;
const [value] = squareTuple([3,4,5] as const) ;
const [spreadValue] = squareSpreadTuple(3,4,5) ;

console.log(`Sum of values is: ${literalValue + value + spreadValue }`)

The example code (with the visible compile errors) is at this playground

UPDATE

I managed to get the red lines to go using the code at this playground. This is by no means elegant for something that's a built-in type where I would expect this to be inferred. Probably there is a better way.

UPDATE 2

If I extend support only to the spread case, then the more minimal approach at this playground works. I find it hard to explain why the non-spread case won't compile using this simpler approach, though.


Solution

  • My inclination would be to write Mapped like this:

    type Mapped<L extends readonly number[]> = { readonly [K in keyof L]: number }
    

    This is using TypeScript's support for using mapped types to turn arrays/tuples into arrays/tuples. Such mappings already preserve the length property without you having to do anything in particular. Presumably all you're trying to do with Mapped is to undo any numeric literal type inference and widen back to number. That's what the above Mapped does. Let's test it out:

    type Test = Mapped<[1, 2, 3]>;
    // type Test = readonly [number, number, number]
    

    Looks good.


    As you noticed, the compiler will not be able to verify that the implementation of squareTuple() will satisfy the call signature's return type. The TS standard library's typings for Array.prototype.map() specify that it returns an array, not a tuple. Some type assertions are in order, such as:

    function squareTuple<NumList extends ReadonlyArray<number>>(nums: NumList): Mapped<NumList> {
      return nums.map(square) as readonly number[] as Mapped<NumList>;
    }
    

    After this everything should work:

    const [literalValue] = [9, 16, 25] as const;
    const [value] = squareTuple([3, 4, 5] as const);
    const [spreadValue] = squareSpreadTuple(3, 4, 5);
    console.log(`Sum of values is: ${literalValue + value + spreadValue}`); // okay
    

    Playground link to code