Skip to content

Custom type guard fails for supertype in union type #61597

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

Closed
otomad opened this issue Apr 20, 2025 · 1 comment
Closed

Custom type guard fails for supertype in union type #61597

otomad opened this issue Apr 20, 2025 · 1 comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@otomad
Copy link

otomad commented Apr 20, 2025

🔎 Search Terms

"type guard", "is object", "not function", "negated types", "union type", "supertype", "exclude"

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about Exclude Isn't Type Negation, Primitives are { }, and { } Doesn't Mean object, Negated types

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.9.0-dev.20250420#code/C4TwDgpgBAcg9sAYgVwHYGNgEs6oDwAqAfFALxQFQQAewEqAJgM5Qoba5QD8UqEAbhABOUAFwUA3ACgpAMzSYcqKFiYB5AEYArCJgAyWANYRCRABT8AhgBtkELuIIBKcVdvRVsBG0W5TUAHoAKigAMig4bV1gKCCAqABvKSgUqCEIYGQhZTc7KABCUnJUZGtrMPDQSDhZKFzoIvIAIkidTCbpAF8ZBl1rS3SodFwmGMtxMzMnMhJ+OCwGaYAfRKhZODhxUaEsVABzKE7pKugAQTIvJAUOfBOaqEsiCUD4hLWNreAd-cOpLFqzKpNG1gAZjGZLE4nMlUpZngFXu9NlBtrsDt0INYmBAYSk4S8oFMZnV5gwen0BtBhqhRlANBMiaRZqTlokjlITlAAEIXeBXdhKPB3WoaJ4EhLdf6EoFRfRGCBmDRQ3F0+HxACi1EgmHEEuep0wyBsDOmTJJC1ZeqkmOxKo0aqgmu1wAmpuZC31huNvAEwmkQA

💻 Code

type NotFunction<T> = T extends Function ? never : T;

function isObjectLike<T>(value?: T): value is NotFunction<T> /* & object */ { // Simplify the code, assume the `value` must be an object.
    return value !== null && typeof value === "object";
}

declare const a: (() => void) | { foo: string };
type A = NotFunction<typeof a>; // { foo: string }
if (isObjectLike(a))
    a; // { foo: string }
else
    a; // () => void

declare const b: (() => void) | {};
type B = NotFunction<typeof b>; // {}
if (isObjectLike(b))
    b; // Expect: {}; Actual: (() => void) | {};
else
    b; // Expect: () => void; Actual: never;

🙁 Actual behavior

I'm trying to implement type guards for lodash's isObjectLike function, and the current implementation works fine for most cases.
To make the code more concise, the step of checking if it is an object is commented here, leaving only the check of whether it is not a function.

If the type of the variable being checked (b in the sample code) is a union type that contains a function and an empty object (or any supertype of the function, like { name: string }), the result of type narrowing will incorrectly include the function as well.

In the sample code, the type of b is (() => void) | {}, and the type of NotFunction<typeof b> is {}, which is correct. However, the type of the function returned value is NotFunction<T> is (() => void) | {}, which is not as expected.

🙂 Expected behavior

The function isObjectLike should narrow the type of b to {}, which consistent with the type of NotFunction<typeof b>.

Additional information about the issue

In order to check if a variable is an object, I have to painfully write value !== null && typeof value === "object" everywhere. The isObjectLike function in lodash encapsulates this operation, but lacks type guards. There is also an isObject function which returns value is object, unfortunately it treats functions as objects.

I'm not sure if the current behavior is expected, and if so, it seems that the isObjectLike function does not have a perfect type guard solution, and may have to wait until the negated type is available.

@jcalz
Copy link
Contributor

jcalz commented Apr 21, 2025

That's how supertypes work; any non-nullish value, including a function, is assignable to {}. If you have X extends Y, that means Y is a supertype of X and every X is also a Y. A union like X | Y is morally the same as just Y, because X is already included in Y. If you have a type guard that only checks the supertype val is Y, then the true case will not eliminate X (it'll still be X | Y) and the false case will eliminate everything (it'll be never). So this behavior is not a bug, it's behaving as intended.

Yes, you need negated types for your use case to be achievable in a straightforward way. You want to say {} & not Function or object & not Function, but there's no not in TS. You can play games like object & {call?: never} or something (see https://stackoverflow.com/questions/52692606/how-to-declare-a-type-in-typescript-that-only-includes-objects-and-not-functions) but this isn't perfect either.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Apr 21, 2025
@otomad otomad closed this as completed Apr 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

3 participants