Search code examples
typescript

When restricting function arguments using an undefined conditional check, how come the order of the conditional matters?


I wrote a generic TypeScript function that ensures a function accepts either zero or two arguments, but not one. I.e. the second parameter is required if the first argument is passed in.

It looks like this:

function bar<T, P>(a?: undefined extends P ? never : T, b?: P) { }

bar('') // ERROR, as desired.

Initially, I had the conditional type the other way around, but that did not work:

function bar<T, P>(a?: P extends undefined ? never : T, b?: P) { }

bar('') // OK, which it should not be.

If the second argument, i.e. P, is not passed in, then it is undefined, which extends undefined. Therefore, how come P extends undefined does not work?

Playground.


Solution

  • The order of the check in a conditional type matters in general, since X extends Y and Y extends X are two different things. This question presumes that P is being inferred as undefined, which would mean both checks were undefined extends undefined, and that would indeed be surprising for there to be a difference. But P is not being inferred as undefined.

    What happens with both

    function foo<T, P>(a?: P extends undefined ? never : T, b?: P) { }
    foo('') // error
    function bar<T, P>(a?: undefined extends P ? never : T, b?: P) { }
    bar('') // okay
    

    is that no value of type P is being passed in for b, and P cannot be inferred from a. The inference for P therefore completely fails. One might hope or expect that b would be "implicitly" undefined and therefore P would be inferred from undefined, but that's not what happens. Inference just fails.

    When type argument inference fails for a generic type parameter, it falls back to its default type argument if one exists, or its constraint if no default exists. Unconstrained type parameters like P have an implicit constraint of unknown. So what actually happens in both cases is that P is instantiated with unknown.

    And since unknown extends undefined is false and undefined extends unknown is true (unknown is the top type in TS; it's a universal supertype) you get different behavior for foo() and bar() there.


    If you want inference failure of P to fall back to undefined, you should make undefined the default type argument:

    function foo<T, P = undefined>(a?: P extends undefined ? never : T, b?: P) { }
    foo(''); // error
    function bar<T, P = undefined>(a?: undefined extends P ? never : T, b?: P) { }
    bar(''); // error
    

    Now P is undefined in both the above cases, and undefined extends undefined is true in both cases so you get the same behavior.

    Playground link to code