Skip to content

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

Closed

Description

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

Activity

  1. whzx5byb commented on Jul 10, 2022

    @whzx5byb

    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
  2. jcalz commented on Jul 11, 2022

    @jcalz
    Contributor

    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.

  3. mikestopcontinues commented on Jul 11, 2022

    @mikestopcontinues
    Author

    Thanks Joe Calzaretta (@jcalz), good tips!

  4. mikestopcontinues commented on Jul 11, 2022

    @mikestopcontinues
    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;
  5. typescript-bot commented on Jul 13, 2022

    @typescript-bot
    Contributor

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

  6. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions