sam256
sam256

Reputation: 1421

Empty optional parameter not inferred as undefined

Why isn't TS inferring the optional parameter in this function as undefined when it's omitted from the call?

function fooFunc<T extends number | undefined>(param?: T){
  let x: T extends undefined ? null : T
  x = param ?? null as any 
  return x
}

It works as expected if explicitly passed undefined but not if the argument is just omitted:

const fooResult1:number = fooFunc(3)

const fooResult2:null = fooFunc(undefined) // <-- this works

const fooResult3:null = fooFunc() // <-- this doesn't; it infers null | number, not null

Things works as expected if I widen the constraint to include unknown, i.e., <T extends number | undefined | unknown>, but it's not a great solution as, in real code, unknown leads to other problems down the road.

What is going on here and is there a fix/workaround?

Playground

Update

It looks like the answer to they "why" part of my question is probably -- it just does. See this SO answer by @jcalz. So I guess my question is really is there any way around it, or do I just have to go down the path of debugging why unknown is giving me problems later on in my code?

Update 2(revised)

Here's my issue with unknown--if I change the function above to

function fooFunc<T extends number | undefined | unknown>(param?: T){
  let x: T extends undefined ? null : T
  x = param ?? null as any 
  return x
}

then it works, in the sense that it will correctly return a null type if called without a parameter, i.e, fooFunc(). but the problem is it will also now allow itself to be called with any arbitrary parameter, e.g., fooFunc("hello"), which is not the desired behavior.

now, you might say, well that's just because you can always add extra arguments in JS, but that's not what's going on here because the same issue happens if the generic is embedded in an object, i.e.,

function bazFunc<T extends number | undefined | unknown>(param: {a: number; b?: T}){
  let x: T extends undefined ? null : T
  x = param.b ?? null as any 
  return x
}

bazFunc({a:1, b:"hello"}) // <-- I don't want this

Refined playground

Edit 3

I'm going to mark @kaya3's answer as accepted because I believe it accurately identifies the source of the problem, which is that the conditional type at the start of the function is distributive and so resolves to the union of the condition as applied to number and undefined. The proposed solution--using overloads--also seems good in many cases.

It did not work in my case because I needed return type inference. However, overriding the default conditional distributive behavior using [] did work. Thus, an alternate solution for the problem in the question is this, which works as I wanted:

function fooFunc<T extends number | undefined>(param?: T){
  let x: [T] extends [number] ? T: null
  x = param ?? null as any 
  return x
}

const fooResult1:number = fooFunc(3)

const fooResult2:null = fooFunc(undefined)

const fooResult3:null = fooFunc()

By using the brackets to not distribute the conditional, it now resolves to just the actual value of T in the non-undefined case, which is what I wanted in the particular instance.

Upvotes: 1

Views: 2537

Answers (2)

kaya3
kaya3

Reputation: 51037

The missing type parameter is not being inferred as unknown here; by hovering over the function call, we can see that the type parameter is inferred as the upper bound number | undefined. Therefore the return type T extends undefined ? null : T, which is a distributive conditional type, resolves as number | null because the number part maps to number and the undefined part maps to null. So in the third example, the function's return type is number | null, which is not assignable to the type null, hence the error.

In this case - when you have a generic function with an optional parameter, but it only makes sense to be generic when the parameter is present - I think the most sensible workaround is to use function overloads. Then you can just directly specify what you want the return type to be when called with no argument, while still letting it be generic when called with an argument.

// overload for calling with no argument
function fooFunc(): null;
// overload for calling with one argument
function fooFunc<T extends number | undefined>(param: T): T extends undefined ? null : T;
// actual implementation
function fooFunc<T extends number | undefined>(param?: T) {
  let x: T extends undefined ? null : T
  x = param ?? null as any 
  return x
}

Playground Link

Upvotes: 1

Nalin Ranjan
Nalin Ranjan

Reputation: 1782

Try this....

function fooFunc<T extends number | undefined>(param?: T){
  // let x: T extends undefined ? null : T
  let x = param ?? null as any 
  return x
}

Upvotes: 0

Related Questions