1

This generic function produces this error: Type (...arg: [TParams] | []) => void is not assignable to type TParams extends undefined ? () => void : (params: TParams) => void

function useExample<
  TParams extends object | undefined = undefined
>(): TParams extends undefined ? () => void : (params: TParams) => void {
  const open = (...arg: [TParams] | []) => {};

  return open;
         ^^^^ Type '(...arg: [TParams] | []) => void' is not assignable to type 'TParams extends undefined ? () => void : (params: TParams) => void'.
}

Depending on what the TParams generic parameter is, the type can be () => void or (params: TParams) => void, so it seems logical to me that that should collapse to (...arg: [TParams] | []) => void. Am I wrong/missing something, or is this something typescripts can't currently handle?

If so, how do I make a function that has a parameter or not depending on a generic parameter? Example: Playground

J2ghz
  • 632
  • 1
  • 7
  • 20
  • 1
    Changed to undefined. At some point during my experimentation I thought I found out that replacing undefined with void will make the first onClickHandler, but trying it again I see it works. The rest still fails in the same way with undefined. – J2ghz Apr 11 '23 at 17:24
  • 1
    Thanks. Anyway this is a design limitation of TS, as per [ms/TS#51488](https://github.com/microsoft/TypeScript/issues/51488). TS doesn't evaluate the conditional for assignability because it's a distributive conditional type; if you don't care about union distribution then you can make it non-distributive [as shown here](https://tsplay.dev/mqYyRm) and it compiles. If you do, then you'll need to work around it with something like a type assertion. Does that fully address the q? If so I'll write up an answer; if not, what am I missing? – jcalz Apr 11 '23 at 17:38

1 Answers1

1

See microsoft/TypeScript#51488 for a canonical answer to this question.


Let's define

type F<T> = T extends undefined ? () => void : (params: T) => void

so that your function is of type

function useExample<
  TParams extends object | undefined = undefined
>() {
  const open: F<TParams> = (...arg: [TParams] | []) => { };
  return open;
}

and we can just refer to F<T> for brevity in what follows.

The problem you're having is that the compiler doesn't see (...arg: [TParams] | []) => void as assignable to F<TParams>, even though from the definition of F<T> it's assignable to both sides of the conditional.


When you have a conditional type that depends on a generic type parameter, like F<T>, the compiler mostly defers evaluation of it and the type is therefore essentially opaque. The compiler doesn't really try to see what values might or might not be assignable to such a type, at least until the type parameter is actually specified with a type argument. This is especially true when the conditional type is distributive (where the type being checked is a bare type parameter, such as T extends ⋯ ? ⋯ : ⋯ as opposed to SomethingElse<T> extends ⋯ ? ⋯ : ⋯).

Distributive conditional types distribute across unions, so if G<T> is a distributive conditional type, G<A | B> will evaluate to G<A> | G<B>. Note that the never type is considered the empty union, and therefore G<never> will be never no matter what (this makes sense if you realize that A | never is A, so if G<A | never> distributes to G<A> | G<never>, then G<A> is always G<A> | G<never> for all possible A and distributive G, so G<never> had probably better be never). Since your type F<T> is distributive, it means that F<never> is never... not () => void or (params: never) => void. Since open is not a valid F<never>, it is technically correct for the compiler to complain about the assignment. Even if it weren't, it's too complicated to figure out if a value will be assignable to F<T> for all possible union-typed T, and the compiler doesn't even try. So you get an error.


What can be done? In general when you have a generic conditional type and you need to assign a value to it, you'll probably have to just use a type assertion and move on:

function useExample<
  TParams extends object | undefined = undefined
>() {
  const open = ((...arg: [TParams] | []) => { }) as F<TParams>;
  return open;
}

But in this particular case, you probably don't actually want F<T> to be distributive in T (right? You don't want F<string | number> to be ((params: string) => void) | ((params: number) => void), do you?) so you can follow the directions in the documentation about distributive conditional types: "To avoid that behavior, you can surround each side of the extends keyword with square brackets." (Also see How to avoid distributive conditional types for more information) Like this:

type F<T> = [T] extends [undefined] ? () => void : (params: T) => void;

And suddenly everything works:

function useExample<
  TParams extends object | undefined = undefined
>() {
  const open: F<TParams> = (...arg: [TParams] | []) => { }; // okay
  return open;
}

That's because for non-distributive conditional types, the compiler does check if the value is assignable to each side and allows the assignment if so. The type F<TParams> will definitely either be () => void or (params: TParams) => void, and since (...arg: [TParams] | []) => void is assignable to both of those, the assignment is accepted.

So that's probably what you want to do in this particular case, even though the general version of this issue (where you might need distributive conditional types) is not solvable without something like type assertions.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360