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

Viewed 1355

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?

2 Answers

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

One possible workaround:

const [first, second] = myArray
assert(first)
assert(second)

first // can use normally here

One advantage is that assert can run in development and can be removed in production if you want to skip the runtime cost. Still kind of ugly though.

Related