18

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?

Ben Wainwright
  • 4,224
  • 1
  • 18
  • 36
  • You can write a user-defined type guard function that has this effect, although it is kinda weird and has edge cases. So instead of `arr.length >= 6` you’d write `hasLengthAtLeast(arr, 6)`. If that sounds like something you want to see I could write up an answer when I get a chance. Otherwise I’d say the answer here is just “no”. Let me know. – jcalz Sep 28 '21 at 22:30
  • tbh, I had considered the type guard option. If I'm honest I was looking to confirm my belief that this just isn't implemented right now. Might be a fun excuse to start digging into the tsc codebase – Ben Wainwright Sep 28 '21 at 23:05
  • @BenWainwright can your please provide a reproducible error in the playground ? – 0.sh Sep 28 '21 at 23:08
  • 1
    Well... no. My question doesn't ask anything about any errors. I think I've been clear enough. – Ben Wainwright Sep 28 '21 at 23:58
  • [This](https://tsplay.dev/WkjXjm) is the type guard function I would possibly suggest; I'll probably write up an answer saying that what you want isn't possible. With links to [microsoft/TypeScript#38000](https://github.com/microsoft/TypeScript/issues/38000) which is listed as "too complex", so I doubt you'd get far with working on this yourself. Answer forthcoming in a bit. – jcalz Sep 29 '21 at 00:42

2 Answers2

23

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Wow. Super comprehensive answer, thanks! – Ben Wainwright Sep 29 '21 at 07:16
  • Replace `readonly any[]` with `{ length: number, [key: number]: unknown }` to make this work for strings too! The `readonly` is redundant, just in case. – Alec Mev Aug 06 '22 at 12:11
  • Sorry, too late to edit, `ArrayLike` is more idiomatic. – Alec Mev Aug 06 '22 at 12:21
  • Thank you for the clear answer. However, the type `LengthAtLeast` doesn't seem to support regular array methods. Example `array.every` – Hssen Nov 04 '22 at 11:14
  • Right, `LengthAtLeast` does not produce an array type. If you need it to still be an array type, you should intersect it with the original array type, as shown in the answer with the output of the type guard `hasLengthAtLeast()`. See [here](https://tsplay.dev/Nl2OeW). If you need `LengthAtLeast` to be an array type, you could redefine it to be `T & ` the version I have, like [this](https://tsplay.dev/wRX6Xm). – jcalz Nov 04 '22 at 15:00
-3

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.

Daniel X Moore
  • 14,637
  • 17
  • 80
  • 92