Search code examples
typescripttypescript-types

Why does typescript suggest object may be undefined, even after explicitly checking it's not defined


Minimal Reproducable Example

type Keys = "FOO" | "BAR";

type ThingType = {
    category: Keys,
    thingName: string;
}

type CategorisedThings = Partial<Record<Keys, string[]>>
type ListedThings = ThingType[]

const categorizeFunction = (listedThings: ListedThings) => {
    const result: CategorisedThings = {}
    for (const thing of listedThings) {
        if (result[thing.category] !== undefined) {             // line 14
            result[thing.category].push(thing.thingName)       // line 15
        } else {
            result[thing.category] = [thing.thingName]
        }
    }
}

In the example above, typescript produces the error Object is possibly 'undefined'. on line 15:

            result[thing.category].push(thing.thingName)
            ^^^^^^^^^^^^^^^^^^^^^^

My Question

I would like to understand why typescript suggests that this might be undefined when in the previous line, I have explicitly checked that the thing isn't undefined.

There are a few ways I am able to work around the problem. For example I could do this:

result[thing.category]?.push(thing.thingName)

The error goes away, but I'm just checking the same thing (not quite, but more or less) twice.

I have tried several approaches in line 14, but they all give the same error.


Solution

  • This is a known longstanding TypeScript issue being tracked at microsoft/TypeScript#10530. TypeScript can only narrow the apparent type of a property access expression like obj[key] if the property's key is of a single literal type like "foo". If the key type is something else, like string, a generic type, or a union of literals, it won't work. That's because currently only the type of the key is tracked, and not its identity. The compiler can't tell the difference between if (test(obj[key])) process(obj[key]) and if (test(obj[key1])) process(obj[key2]) if key, key1, and key2 are all of the same type. If that type is a single literal type like "foo" then it's fine to do some narrowing, because we know that obj[key1] and obj[key2] refer to exactly the same property. But if that type is something else, then it's possible you're testing one property and then processing another unrelated one.

    In your example code, the key is thing.category, whose type is Keys, a union of string literals "FOO" and "BAR". So the compiler doesn't know if the expression result[thing.category] will refer the same thing every time it appears (I suppose it's technically possible for that to be false, if you wrote thing.category = "BAR" somewhere between the test and the processing... but even if you saved thing.category to a new const key = thing.category it would be equally unsure about result[key]). So it won't narrow for you.

    Maybe someday this will be improved by tracking the identity of the key in addition to the type (and possibly only if the key is a const or somehow known not to have been modified), but until and unless that happens you need to work around it.


    The most common workaround is to save the property to a new variable and write your code with that variable instead, like const v = result[key.category] and then you'd be writing if (v) v.push(⋯), but in this case you're also trying to set result[key.category] which you can't do through another variable.

    So instead I'd probably advise you to use the nullish coalescing assignment (??=) operator to initialize result[thing.category] if necessary and then use the known-non-nullish result:

    const categorizeFunction = (listedThings: ListedThings) => {
      const result: CategorisedThings = {}
      for (const thing of listedThings) {
        (result[thing.category] ??= []).push(thing.thingName)
      }
    }
    

    Playground link to code