Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type inference in function body of optionalArg || {} is {} #39434

Closed
ben-dyer opened this issue Jul 6, 2020 · 4 comments
Closed

Type inference in function body of optionalArg || {} is {} #39434

ben-dyer opened this issue Jul 6, 2020 · 4 comments

Comments

@ben-dyer
Copy link

ben-dyer commented Jul 6, 2020

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:

@jack-williams
Copy link
Collaborator

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

@ben-dyer
Copy link
Author

ben-dyer commented Jul 6, 2020

@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.

@jack-williams
Copy link
Collaborator

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

@ben-dyer
Copy link
Author

ben-dyer commented Jul 7, 2020

@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 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants