Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 407
Ben Wainwright Avatar asked Sep 28 '21 21:09

Ben Wainwright


1 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

like image 121
jcalz Avatar answered Oct 11 '22 21:10

jcalz