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?
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