1

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;
};
4
  • Probably because an indexer [] is allowed to change the items in the array. After the first if, numList[index] could be returning something else... Commented Oct 4, 2022 at 15:11
  • Thank you for your comment. When I specify index like numList[0].key1 can also avoid the error. Commented Oct 4, 2022 at 15:16
  • Could you add a if (!numList[index]) return; check at the beginning of your function so that we don't need to talk about unchecked indexed accesses, which isn't the point of your question? Commented Oct 4, 2022 at 15:47
  • Thanks. As you said, I added. That's my point to ask. Commented Oct 4, 2022 at 16:15

3 Answers 3

1

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.

Sign up to request clarification or add additional context in comments.

1 Comment

That's really understandable, thanks to it. Accepted as answer.
1

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.

2 Comments

This is a good point about unchecked indexed accesses, but it seems to be beside the point of the question. That is, if they just add a if (!numList[index]) return check at the beginning of the function, there's no hazard with unchecked indices, but they still have the same issue with "possibly null" they're asking about. I mean, this is just my opinion; obviously you wouldn't have written this answer up if you thought unchecked indexed accesses were a red herring.
^ @jcalz Yeah, I think it's actually applicable here: e.g. the code in question ignores 0 with the implicit coercion: if (!numList[index].key1) return;. I think that's probably a bug and I interpret it as though the OP is still learning about the concept of null-checking in the type system, so they probably need knowledge about indexed access as well.
0

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 Comments

This happens even if index is a const that can never change, though. (Observe). The real issue is github.com/microsoft/TypeScript/issues/10530; the type of index is wider than a single literal type, and the compiler checks by type of indexer and not identity
Thank you, @jcalz for this information. I understood that's an issue in narrowing type in typescript. I will just avoid this by using variable which is said in github.com/microsoft/TypeScript/issues/10530;

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.