TypeScript Version: 3.7.5, 3.8.2, 3.8.3, 3.9.2, 4.0.0-beta (ts playground versions)
Search Terms: optional arguments, logical or, empty object
Code
// paste into ts playground
type DataObject = { datafield: string }
function test(arg?: DataObject): void {
const data = arg || {};
typeof data // {}
if ("datafield" in data) {
typeof data // never
}
}
Expected behavior:
typeof data // {} | DataObject
Actual behavior:
typeof data // {}
Only observed for empty object - making the following change shows expected behavior:
// paste into ts playground
type DataObject = { datafield: string }
function test(arg?: DataObject): void {
const data = arg || { otherfield: 1 };
typeof data // DataObject | { otherfield: number }
if ("datafield" in data) {
typeof data // DataObject
}
}
Playground Link:
https://www.typescriptlang.org/play?ts=3.9.2#code/C4TwDgpgBAIghsOB5ARgKwgY2FAvFAbygBME4AzASwgBtiAuKAZ2ACdKA7AcygF8AocgFcO2SgHsOUYBBYAKOKy4B+RvESoM2AJSMAbuMrFC-KGaiZJLEmTxRFPAD6PCfANynzoSOPI3EUAD0gYQC5lCUfnIARKSIVLTE0RFScXDaJuHh3hC+-nBBIRwQehCsnmYCAoIiYpLSssAATApKqrBkmljAulAGRpnmlhzWaXYOUM6u4sAAFmUJdIwAjO4V0uC5fmPBHRro3ZPTcwvUS1DLYeaRUDFpi0kp+RkE69mbeTsh6sgH2OtVIA
Related Issues:
This is right behaviour and is an artifact of subtype reduction for conditionals.
The empty object type is a super type of DataObject and therefore subsumes the type in the union.
In contrast, DataObject and { otherfield: 1 } are unrelated so neither subsumes the other in the union, and both are retained.
I can see why this is confusing. The intent is that {} is the object _known_ to have no fields. From TypeScript's POV, this is the object with _no known fields_, but potentially unknown fields.
The fact that it narrows to never is slightly awkward and this is tracked in places like #38608 and #21732.
One workaround might be declaring data as const data: Partial<DataObject> = arg || {};.
Another workaround is to abuse in narrowing and add a synthetic disjoint property.
type DataObject = { datafield: string }
function test(arg?: DataObject): void {
const empty: { missing?: undefined } = {};
const data = arg || empty;
typeof data // {}
if ("datafield" in data) {
typeof data // DataObject
}
}
@jack-williams Thank you for the explanation, and for pointing to the related issues.
The empty object type is a super type of DataObject and therefore subsumes the type in the union.
Thanks for pointing that out; I had some intuition about {} as an object literal (and strict checks) and perhaps I was over-extending that intuition to its inferred type.
One workaround might be declaring data as const data: Partial
= arg || {};.
I had found a workaround declaring:
const data: DataObject | {} = arg || {}
If I understand correctly then, DataObject | {} is not a distinct type from {} in terms of its members, but does prevent narrowing to never just because union types are iterated over rather than reduced to a simplified structural type.
I'm still a little unsure about how this one works:
In contrast, DataObject and { otherfield: 1 } are unrelated so neither subsumes the other in the union, and both are retained.
For instance if I extend the example slightly:
type DataObject = { datafield: string; extrafield: number } // add common field here
function test(arg?: DataObject): void {
const data = arg || { extrafield: 1 };
typeof data // DataObject | { extrafield: number }
if ("datafield" in data) {
typeof data // DataObject
}
}
The type { extrafield: number } would be again a supertype of DataObject, but seems this time the inferred type is not a subsumed supertype but the union type.
If I understand correctly then, DataObject | {} is not a distinct type from {} in terms of its members, but does prevent narrowing to never just because union types are iterated over rather than reduced to a simplified structural type.
Yep, and while DataObject | {} and {} are semantically the same, TypeScript only forcibly simplifies the union in some cases (conditional branches being one).
The type { extrafield: number } would be again a supertype of DataObject, but seems this time the inferred type is not a subsumed supertype but the union type.
Object literals (with fields) create an object literal type with a special 'fresh' flag. Freshness tells TypeScript that the object literal is closed, so the fresh object type { extrafield: number } really does have one property, and the freshness makes it distinct from the non-fresh object literal type { datafield: string; extrafield: number }. Freshness is abit of a compiler detail used to help with certain type-checking features.
If I were to add a layer of indirection the freshness is lost, and the types are reduced as expected.
type DataObject = { datafield: string; extrafield: number } // add common field here
function test(arg?: DataObject): void {
const data = arg || ({ extrafield: 4 } as { extrafield: number });
typeof data // { extrafield: number }
if ("datafield" in data) {
typeof data // never
}
}
@jack-williams Thanks again for the explanations, that clears things up nicely.
Especially this point clarified a lot (even if it is an implementation detail):
Object literals (with fields) create an object literal type with a special 'fresh' flag.
Closing this one now :+1:
Most helpful comment
Yep, and while
DataObject | {}and{}are semantically the same, TypeScript only forcibly simplifies the union in some cases (conditional branches being one).Object literals (with fields) create an object literal type with a special 'fresh' flag. Freshness tells TypeScript that the object literal is closed, so the fresh object type
{ extrafield: number }really does have one property, and the freshness makes it distinct from the non-fresh object literal type{ datafield: string; extrafield: number }. Freshness is abit of a compiler detail used to help with certain type-checking features.If I were to add a layer of indirection the freshness is lost, and the types are reduced as expected.