Typescript: Type inference in function body of optionalArg || {} is {}

Created on 6 Jul 2020  路  4Comments  路  Source: microsoft/TypeScript


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:

Most helpful comment

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

All 4 comments

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:

Was this page helpful?
0 / 5 - 0 ratings

Related issues

manekinekko picture manekinekko  路  3Comments

weswigham picture weswigham  路  3Comments

seanzer picture seanzer  路  3Comments

bgrieder picture bgrieder  路  3Comments

siddjain picture siddjain  路  3Comments