javascripttypescript

Why is the value at an array index, addressed by a loop's counter, still possibly undefined when just checked?


Using VSCode/Typescript. noUncheckedIndexedAccess is enabled in TS Config.

Repro on TS Playground

I am trying to confirm the presence of values in a Float32Array in a for-loop, and reference them immediately. However, if I used the counter declared in the for-loop to validate the value at an index, and then reference it immediately after, it doesn't seem to acknowledge the validation. e.g.:

const collection = new Float32Array(10);
for (let idx = 0; idx < collection.length; idx++) {
 if (collection[idx] !== undefined) {
   collection[idx] = collection[idx] * 2;
 }
}

Would give me a "Object is possibly undefined" for collection[idx], despite the check just before.

Screenshots: VSCode squiggly

Object is possibly undefined

However, if I were to reassign idx to another value within the loop, and use that as the index instead, it doesn't complain:

const collection = new Float32Array(10);
for (let idx = 0; idx < collection.length; idx++) {
  const localIdx = idx;
  if (collection[localIdx] !== undefined) {
    collection[localIdx] = collection[localIdx] * 2;
  }
}

Screenshot: No complaints

This workaround feels unnecessary, though. Is this a legitimate error, or a bug in the validation tools? Thanks for any insight!


Solution

  • Let's examine a different scenario with the same root cause. This one doesn't depend on noUncheckedIndexedAccess:

    function f(collection: (string | number)[], idx: number) {
      if (typeof collection[idx] === 'number') {
        const error: number = collection[idx];
        //    ^^^^^
        // Type 'string | number' is not assignable to type 'number'.
        //   Type 'string' is not assignable to type 'number'.
      }
      idx = idx + 1;
    }
    

    Silly TypeScript; we just proved that collection[idx] is a number!

    If we don't reassign idx, the error goes away:

    function f(collection: (string | number)[], idx: number) {
      if (typeof collection[idx] === 'number') {
        const notAnError: number = collection[idx];
      }
    }
    

    As explained in microsoft/TypeScript#57847, narrowing with a variable index only applies to:

    obj[key] where key is a const variable, or a let variable or parameter that is never targeted in an assignment

    "Never targeted in an assignment" is checked conservatively. Narrowing doesn't happen even if assignment occurs in another scope after we've accessed collection[idx], nor when idx is a potentially-reassignable let/var binding in global scope, even with no explicit reassignment (because in that case it could be reassigned elsewhere, maybe even somewhere TypeScript doesn't know about).

    To drive the point home, here's a case where narrowing would obviously be unsound:

    if (typeof collection[idx] === 'number') {
      idx = idx + 1;
      // Now `collection[idx]` isn't the same element we checked above:
      const oops: number = collection[idx];
    }
    

    To specifically answer your question:

    Is this a legitimate error, or a bug in the validation tools?

    I'd say neither.

    A human reading the code in your example can see that it's obviously safe (so the error is arguably not "legitimate"), but at the same time this behavior is a known/intended consequence of the current control flow analysis logic (so it's not a "bug"). No matter how smart the type checker gets, if you try hard enough you'll always be able to find cases like this—static type checkers necessarily reject some valid programs due to Rice's Theorem1.


    microsoft/TypeScript#61176 proposes making control flow analysis smarter so that when using a literal-typed index, narrowing would always apply (even when the binding is a let that is reassigned). However, that enhancement wouldn't be enough to address your for loop use case, as idx obviously can't be literally-typed as some fixed number there (it's incremented in each loop iteration).


    1 Let's forget for a moment that TypeScript's type system is neither decidable nor sound, so Rice's Theorem need not apply.