Typescript not doing case analysis correctly

Viewed 53

Say we want to define a function that takes in another function and its parameter, and outputs the function called with the parameter. This is a very good example where we'd want typescript to do proper case analysis. See below.

let fns = {
  A: (x: string) => 'a' + x,
  B: (y: number) => 100 + y
}

type fn = typeof fns[keyof typeof fns]

const combine = <T extends fn>(fn: T, param: Parameters<T>[0]) => {
  return fn(param)
}

The problem is that typescript is giving me the following error on the word param (on the line with the word return): Argument of type 'string | number' is not assignable to parameter of type 'never'. Type 'string' is not assignable to type 'never'.ts(2345)

For some reason it's combining both types of input into one type, instead of understanding that T will only be one or the other. Help!

1 Answers

(Using A and B to refer to the signatures separately)

TypeScript is actually correct here because T extends fn does not mean that T can only be "A" or "B". T extends fn is a constraint that means T must be assignable to fn. Sure, "A" and "B" are assignable to fn, but the type A | B is also assignable to fn.

That means someone could call your function like this:

combine<fn>(fns.A, 0);

If TypeScript were to allow this, then this call would be valid! This would result in unexpected and undesirable behavior. But don't fret, I've got two possible workarounds, each with their pros and cons.


One is to use an external signature and write the internal signature like this:

function combine<T extends fn>(fn: T, param: Parameters<T>[0]): ReturnType<T>;
function combine<T extends (x: unknown) => R, R>(fn: T, param: Parameters<T>[0]): R {
  return fn(param);
}

It's a bit ugly and unsafe but it does work while retaining the original signature. However, like I mentioned above, people can call it with the type fn instead of "A" or "B".

Playground


Similar to the first, this one uses individual overloads instead. It's slightly better than the first because it prevents users from calling it with unions of functions, but it's a lot more to type.

function combine(fn: typeof fns.A, param: string): string;
function combine(fn: typeof fns.B, param: number): number;
function combine<T extends (x: unknown) => R, R>(fn: T, param: Parameters<T>[0]): R {
  return fn(param)
}

Playground

There's probably better solutions, but I haven't had my morning coffee yet. Hopefully you at least understand why it's an error, but I'll still edit the answer with better solutions when I think of them.

Related