Why assigning an array item to variable can avoid TS possibly null error, but directly accessing it can't?

900 views Asked by At

I would like to know the difference between assigning an array item to variable and accessing directly to it. As I show in the code below, #1 hits the possibly null error, but #2 and #3 doesn't. Those results are same, right? Anyone knows how this is working?

interface NumInterface {
  key1: number | null;
}

const numList: NumInterface[] = [{ key1: 1 }];

const fooFunc = (index: number) => {
  // pass unchecked indexed accesses
  if (!numList[index]) return;

  // #1
  // can not avoid "possibly null"
  if (!numList[index].key1) return;
  numList[index].key1 + 1; // this outputs "Object is possibly 'null'."

  // #2
  // can avoid "possibly null"
  const target = numList[index].key1;
  if (!target) return;
  target + 1;

  // #3
  // can avoid "possibly null"
  if (!numList[0].key1) return;
  numList[0].key1 + 1;
};
3

There are 3 answers

1
jcalz On BEST ANSWER

The underlying issue here is a longstanding missing feature of TypeScript requested microsoft/TypeScript#10530.

TypeScript's control flow analysis, which lets the compiler see that x must be truthy after if (!x) return;, only works on property accesses when the property name is a known literal like 0 or "foo". If the property name is a wide type like number or string, or if it is a union type like 0 | 1 or "foo" | "bar", or if it is a generic type like I extends number or K extends string, the control flow analysis doesn't happen.

That's because currently the compiler just looks at the type of the indexer and not its identity. It can't see the difference between if (!obj[k]) return; obj[k].toFixed() and if (!obj[k1]) return; obj[k2].toFixed() if k, k1, and k2 are all of the same type. If that type happens to be a single literal type like 0 or "foo", then control flow analysis is fine, because then k1 and k2 would definitely hold the same value even though they are different variables. But if it's a wide type or a union type or a generic type, then control flow analysis is not safe because it could hold different values.

Again, this is a missing feature, not a problem with your code. Obviously there is a difference between if (!obj[k]) return; obj[k].toFixed() and if (!obj[k1]) return; obj[k2].toFixed(). The fact that the indexer k is identical in both checks and is not reassigned between them guarantees that you are checking a property and then acting on the same property. But currently the compiler doesn't notice or act on this identity. The first time an attempt to fix microsoft/TypeScript#10530 caused an unacceptable degradation in compiler performance. It's possible that at some point it will be revisited with more performant code. If you want to go to that issue and give it a you can. It wouldn't hurt, but it probably won't help much either.


So that's why

if (!numList[index].key1) return;
numList[index].key1 + 1; // this outputs "Object is possibly 'null'."

doesn't work but

if (!numList[0].key1) return;
numList[0].key1 + 1;

does. And the reason

const target = numList[index].key1;
if (!target) return;
target + 1;

works is because target is a const variable and you are not checking a property anymore. And control flow checks on variables like target do notice the identity of variables. The missing feature is identity blindness specifically when it comes to keys/indexers, not all values everywhere.

And, for what it's worth, copying a property to a new variable like target and then using only that variable is the standard workaround for ms/TS#10530. So until and unless that issue is addressed, you should probably keep doing that.

2
htho On

I've stumbled upon this my self a few times.

The problem is that in the first case, the value of index and key1 might change between your check and the line where you access the data.

In the second case, there is no way target might change between the check and you accessing the value.

2
jsejcksn On

Let's look at your code with an additional strict feature enabled: noUncheckedIndexedAccess

This feature configures the compiler to warn you when you try to access a property on an indexed object by its indexed property accessor (in your case, an element in an array by a numeric index).

In any given array, there can't be an element at every possible numeric index: no array has infinite members. This setting recognizes that and helps you avoid potential related errors.

Here's your existing code in the TS Playground:

interface NumInterface {
  key1: number | null;
}

const numList: NumInterface[] = [{ key1: 1 }];

const fooFunc = (index: number) => {
  // #1
  if (!numList[index].key1) return; /*
       ~~~~~~~~~~~~~~ Object is possibly 'undefined'.(2532) */
  numList[index].key1 + 1; /*
  ~~~~~~~~~~~~~~ Object is possibly 'undefined'.(2532)
  ~~~~~~~~~~~~~~~~~~~ Object is possibly 'null'.(2531) */

  // #2
  const target = numList[index].key1; /*
                 ~~~~~~~~~~~~~~ Object is possibly 'undefined'.(2532) */
  if (!target) return;
  target + 1;
};

Now, let's focus on what's happening inside your function body:

TS Playground

const numInterface = numList[index];
    //^? const numInterface: NumInterface | undefined

const value = numInterface.key1; /*
              ~~~~~~~~~~~~
Object is possibly 'undefined'.(2532) */

If we access an element in the array by the index and assign it to a variable, the result will be the element at the index (if it exists in the array) or undefined (if it doesn't).

Then we can try to access the property key1 of the numInterface variable to assign it to a new variable value: If the numInterface didn't exist at the index we used, then — at runtime — an error will occur: TypeError: Cannot read properties of undefined. TypeScript is warning us about this and trying to prevent us from making a mistake.

In order to fix this, we need to be sure that the numInterface exists before trying to access its key1 property, and we can use an if statement to do so:

TS Playground

const numInterface = numList[index];
    //^? const numInterface: NumInterface | undefined

if (numInterface) {
  const value = numInterface.key1;
      //^? const value: number | null
}

But if we try to add 1 to the value at this point, we'll see another compiler error because the value is still potentially null:

TS Playground

const numInterface = numList[index];
    //^? const numInterface: NumInterface | undefined

if (numInterface) {
  const value = numInterface.key1;
      //^? const value: number | null

  value + 1; /*
  ~~~~~
  Object is possibly 'null'.(2531) */
}

To do this safely, we need another conditional check, to be sure that the value is a number type before performing the addition operation:

TS Playground

const numInterface = numList[index];
    //^? const numInterface: NumInterface | undefined

if (numInterface) {
  const value = numInterface.key1;
      //^? const value: number | null

  if (typeof value === 'number') {
    value + 1; // Ok!
  }
}

Now the compiler doesn't complain about anything else because we've performed all the checks needed to be sure that the values are of the expected types and that they're being used appropriately.

If you only ultimately need the value at the key1 property on the element, then you can use the Optional chaining operator (?.) to access the property in a single expression — and in the case that the element doesn't exist, the expression will short-circuit and evaluate to undefined (as you can see in the inferred union type below):

TS Playground

const value = numList[index]?.key1;
    //^? const value: number | null | undefined

if (typeof value === 'number') value + 1; // Ok!

It takes a bit more syntax to write type-safe code sometimes, but it's worth it to know that you've prevented errors and it can help you to have greater confidence about the performance and correctness of your code.