Skip to content

Function arg type inference (incorrectly) unions with null but excludes others #49850

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
mikestopcontinues opened this issue Jul 10, 2022 · 5 comments
Labels
Duplicate An existing issue was already created

Comments

@mikestopcontinues
Copy link

Bug Report

🔎 Search Terms

function arg generic inconsistent type inference
infer func arguments breaks on null

🕗 Version & Regression Information

This is the behavior in every version I tried, and I reviewed the FAQ for entries about functions and generics

⏯ Playground Link

Playground link with relevant code

Discord thread, no solutions

💻 Code

const matchStrict = <T>(test: (x: unknown) => x is T) => {
  return (...options: Array<T | undefined>): T | undefined => {
    return options.find((o) => test(o));
  };
};

const matchLoose = <T>(
  test: (x: unknown) => x is T,
  ...options: Array<T | undefined>
): T | undefined => {
  return options.find((o) => test(o));
};

const isStr = (x: unknown): x is string => {
  return typeof x === 'string';
};

// Works as expected
matchStrict(isStr)(123, 'a'); // ✅ 'number' is not assignable
matchStrict(isStr)(null, 'a'); // ✅ 'null' is not assignable

// Inconsistent behavior
matchLoose(isStr, 123, 'a'); // ✅ 'number' is not assignable
matchLoose(isStr, null, 'a'); // ⛔️ WTF! matchLoose<string | null>

matchLoose<string>(isStr, null, 'a'); // ✅ 'null' is not assignable

🙁 Actual behavior

matchLoose(isStr, 123) correctly excludes number, but matchLoose(isStr, null) incorrectly becomes matchLoose<string | null>.

🙂 Expected behavior

I expect matchLoose(isStr, null) and matchLoose(isStr, 123) to both throw errors, because isStr narrows <T> strictly. (I expect the intersection to work regardless of argument order too.)

@whzx5byb
Copy link

A workaround could be using the type of type guard function as generic:

function matchLoose<T extends Function>(test: T, ...options: T extends (x: unknown) => x is infer R ? Array<R> : never) {
  return options.find((o) => test(o));
}

function isStr(x: unknown): x is string {
  return typeof x === 'string';
}

matchLoose(isStr, 123, 'a'); // Error as expected
matchLoose(isStr, null, 'a'); // Error as expected

@jcalz
Copy link
Contributor

jcalz commented Jul 11, 2022

Seems like a duplicate of #14829. You don't want the options parameter to be used as an inference site for T.

TypeScript has some heuristics for accepting/rejecting/combining inference candidates; string from one and number from another tends to be rejected, but string from one and null from another tends to be accepted and combined via union. (See SO question, #19596, #44312, and probably a zillion other places I can't find right now).

Workarounds here usually involve lowering the priority of the options inference site, or otherwise blocking the inference. Your curried function is one way to do it, since T is already specified by the time the returned function is called. You can also define a NoInfer<T> utility and write the type of options as Array<NoInfer<T> | undefined>. Possibilities are listed in #14829. You can try type NoInfer<T> = T & {} or type NoInfer<T> = [T][T extends unknown ? 0 : 1] and see if that works for your use cases.

@mikestopcontinues
Copy link
Author

Thanks @jcalz, good tips!

@mikestopcontinues
Copy link
Author

For other who arrive here, I found this to be an excellent solution:

function match<T, U extends T>(test: T, ...options: Array<U | undefined>): T | undefined;

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Jul 11, 2022
@typescript-bot
Copy link
Collaborator

This issue has been marked as a 'Duplicate' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

5 participants