typescriptnarrowing

Type safe way of narrowing type of arrays by length when noUncheckedIndexedAccess is true


Given a function that takes a single string[] argument myArray.

If I evaluate the .length property and that property is greater than 0, then (assuming my variable isn't any in disguise) its impossible for myArray[0] to be undefined. However, if I enable noUncheckedIndexedAccess, its type will be string | undefined

const myFunc = (myArray: string[]) => {
   if(myArray.length > 0) {
     const foo = myArray[0] // now typed at 'string | undefined'
   }
}

Now I can change the if statement to evaluate myArray[0], and undefined is removed from the type as you'd expect. But what if I now want to check that the length of the array is greater than 6. I don't want to have to do the same thing for indexes 0-5 to correctly narrow down the type. e.g:

const myFunc = (myArray: string[]) => {
   if(myArray[0] && myArray[1] && myArray[2] && myArray[3] && myArray[4] && myArray[5]) {
      const foo = myArray[0] // now typed at 'string', but this is uggggly
   }
}

Is there a more elegant way of narrowing the type based on the length of the array or am I going to have to look into contributing to the TypeScript codebase?


Solution

  • As you suspected, TypeScript does not automatically narrow the type of an array based upon a check of its length property. This has been suggested before at microsoft/TypeScript#38000, which is marked as "too complex". It looks like it had been suggested prior to that, at microsoft/TypeScript#28837, which is still open and marked as "awaiting more feedback". Possibly you could go to that issue and leave feedback as to why such a thing would be helpful to you and why the current solutions aren't sufficient, but I don't know that it'll have much effect. Either way I doubt that the TS team is taking pull requests to implement such a feature right now.

    In the absence of any automatic narrowing, you could instead write a user defined type guard function that has the effect you want. Here's one possible implementation:

    type Indices<L extends number, T extends number[] = []> =
        T['length'] extends L ? T[number] : Indices<L, [T['length'], ...T]>;
    
    type LengthAtLeast<T extends readonly any[], L extends number> = 
      Pick<Required<T>, Indices<L>>
    
    function hasLengthAtLeast<T extends readonly any[], L extends number>(
        arr: T, len: L
    ): arr is T & LengthAtLeast<T, L> {
        return arr.length >= len;
    }
    

    The Indices<L> type is intended to take a single, relatively small, non-negative, integral numeric literal type L and return a union of the numeric indices of an array with length L. Another way to say this is that Indices<L> should be a union of the nonnegative whole numbers less than L. Observe:

    type ZeroToNine = Indices<10>
    // type ZeroToNine = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
    

    This type works by leveraging recursive conditional types along with variadic tuple types to walk from 0 up to L. Recursive types tend to work fine until they don't, and one big caveat here is that things will be weird or even throw errors if you pass in an L that is too large, or fractional, or negative, or number, or a union. That's a big caveat for this approach.

    Next, LengthAtLeast<T, L> takes an array type T and a length L, and returns an object which is known to have properties at all the indices of an array of at least length L. Like this:

    type Test = LengthAtLeast<["a"?, "b"?, "c"?, "d"?, "e"?], 3>
    /* type Test = {
        0: "a";
        1: "b";
        2: "c";
    } */
    
    type Test2 = LengthAtLeast<string[], 2>
    /* type Test2 = {
        0: string;
        1: string;
    } */
    

    Finally, hasLengthAtLeast(arr, len) is the type guard function. If it returns true, then arr is narrowed from type T to T & LengthAtLeast<T, L>. Let's see it in action:

    const myFunc = (myArray: string[]) => {
        if (hasLengthAtLeast(myArray, 6)) {
            myArray[0].toUpperCase(); // okay
            myArray[5].toUpperCase(); // okay
            myArray[6].toUpperCase(); // error, possibly undefined
        }
    }
    

    Looks good. The compiler is happy to allow you to treat myArray[0] and myArray[5] as defined, but myArray[6] is still possibly undefined.


    Anyway, if you do decide to go for a type guard, you might want to balance complexity against how much you need to use it. If you're only checking length a few places, it might be worthwhile just to use a non-null assertion operator like myArray[0]!.toUpperCase() and not worry about getting the compiler to verify type safety for you.

    Or, if you have no control over the value of len, then you might not want a fragile recursive conditional type, and instead build something more robust but less flexible (like maybe an overloaded type guard that only works for specific len values like in a comment on microsoft/TypeScript#38000).

    It all comes down to your use cases.

    Playground link to code