Search code examples
typescript

property is possibly 'undefined' while it's been explicitly defined


Considering this very simple code:

type ExempleType = {
  someProperty?: string[];
};

const someVar: ExempleType = { someProperty: [] };

someVar.someProperty.push('test'); // => 'someVar.someProperty' is possibly 'undefined'.ts(18048)

I don't understand why typescript returns the error on this last line. someProperty cannot be undefined since it was explicitly defined.

If I slightly update the code and define someProperty just after the variable instantiation, it works:

type ExempleType = {
  someProperty?: string[];
};

const someVar: ExempleType = {};
someVar.someProperty = [];

someVar.someProperty.push('test'); // => No error!

This looks like a bug in Typescript to me... But on another end, I would expect such a bug to be caught (and fixed) very quickly, so it's probably not a bug.

So did I miss something that could explain this error?


Solution

  • If you annotate a variable with a (non-union) type like ExempleType, then that's all TypeScript knows about the type of that variable. Any more specific information about the type of a value you assign to it is simply not tracked. There is such a thing as assignment narrowing, but that only works for union types. So if you write let x: string[] | undefined = [], then x is narrowed to string[] after that assignment. But the similar-looking let y: { x: string[] | undefined } = { x: [] } doesn't work the same way because you are assigning to a non-union object type whose property happens to be a union. It would be nice if assignment narrowing worked on non-union types, also, but it doesn't happen (see this comment on microsoft/TypeScript#8513 for more information).

    This explains the difference between const someVar: ExempleType = { someProperty: [] }; and const someVar: ExempleType = {}; someVar.someProperty = [];. In the latter case you have assigned the property, which is effectively a union type (optional properties behave similarly to unions with undefined) so it is narrowed to string[]. Subsequent direct property accesses like someVar.someProperty will then see string[], but someVar itself does not get narrowed:

    const someVar: ExempleType = {};
    someVar.someProperty = [];
    someVar.someProperty.push('test'); // no error
    function foo(r: Required<ExempleType>) { }
    foo(someVar); // error!  Types of property 'someProperty' are incompatible.
    

    Generally speaking if you want TypeScript to keep track of specific information from initializers (and you're not planning to do a lot of mutation) then you might want to avoid annotating entirely, and use the satisfies operator to check that the initializer is compatible with that type.

    This is a little messy with empty arrays, because [] will end up being inferred as never[], even with satisfies. There's an open issue at microsoft/TypeScript#51853. Right now the "safe" way that uses satisfies is wordy:

    const someVar = {
        someProperty: [] satisfies string[] as string[]
    } satisfies ExempleType;
    
    /* const someVar: { someProperty: string[]; } */
    

    The expr satisfies T as T construction effectively first checks that expr is assignable to T and then widens it to T, so [] satisfies string[] as string[] ends up with [] as type string[] after checking that it works. It's easier to just write [] as string[] but that's a little less safe. And then the satisfies at the end makes sure that { someProperty: string[] } is compatible with ExempleType, which it is... but someVar is now narrowed than ExempleType; it's someProperty is known to be string[], and the rest of your code works as desired:

    someVar.someProperty.push('test'); // okay
    function foo(r: Required<ExempleType>) { }
    foo(someVar); // okay
    

    Playground link to code