TypeScript default function for higher-order function generic type

Viewed 105

I have a higher-order function which correctly infers the type of its returned function based on a generic function passed as an argument, might be easier to understand with a simple example:

function someFunction<T>(f: (s: string) => T) {
  return function(s: string): T {
    return f(s)
  }
}

So, if I pass a function that returns a string, the returned function will correctly infer the type:

const funcStr = someFunction((s: string) => s)
//    ^ const funcStr: (s: string) => string

And if I pass a function that returns a number it will return a function that in turn returns a number:

const funcNum = someFunction((s: string) => Number(s))
//    ^ const funcStr: (s: string) => number

Now I want to add a default value to the higher-order function, but haven't been able to make it work:

// Type '(s: string) => number' is not assignable to type '(s: string) => T'.
function someFunction<T>(f: (s: string) => T = (s: string) => Number(s)) {
//                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  return function(s: string): T {
    return f(s)
  }
}

How can I add a default function to the higher-order function and still make it infer the type if another function is used?

Working TS playground.

1 Answers

Technically speaking the compiler is right to complain. You can always use a type assertion to suppress the error on the default function parameter, as well as providing a default type parameter for T so the compiler knows what to do if it can't infer T from the argument:

function someFunction<T = number>(
  f: (s: string) => T = ((s: string) => Number(s)) as any
) {
  return function (s: string): T {
    return f(s)
  }
}

And this will behave exactly as you want:

const fNum = someFunction()
console.log(fNum("3.14159").toFixed(2)); // 3.14
const fStr = someFunction((s: string) => s)

But the problem with this approach is that the type parameter T is specified by the caller of someFunction(), not by the implementation. And so nothing will prevent a malicious and/or confused caller from doing something like this:

const fOops = someFunction<string>();

The caller has specified that T is a string, and even though T's default is number, it has been overridden by an explicit string type. So the compiler thinks that fOops is of type (s: string) => string, but of course at runtime we know it won't be:

try {
  fOops("oopsie").toUpperCase(); // no compiler error
} catch (e) {
  console.log(e); // fOops(...).toUpperCase is not a function
}

And that's why the compiler complains; it is saying that it can't be sure that the default value of f will be of a type appropriate for the T specified by the caller.

If you don't care about this possibility, then this approach is fine and requires the minimum change to your code.


If you want to prevent the possibility of an fOops-like situation, you could change someFunction() to be an overloaded function to have two call signatures:

// call signatures
function someFunction(): (s: string) => number;
function someFunction<T>(f: (s: string) => T): (s: string) => T;

If you call someFunction() with no argument, then you are invoking the first call signature, and the return type must be (s: string) => number; there's no generic type parameter on this call signature at all.

Otherwise, if you call it with a callback argument, then you are invoking the second call signature; there's a type parameter T corresponding to the return type of that callback.

Then the implementation can be similar to before, but you loosen the types up to prevent a complaint (here we will just use any as the return type):

function someFunction(f: (s: string) => any = (s: string) => Number(s)) {
  return function (s: string) {
    return f(s)
  }
}

Now the calls you intend to support still work:

const fNum = someFunction()
console.log(fNum("3.14159").toFixed(2)); // 3.14

const fStr = someFunction((s: string) => s)

but it is impossible to invoke the zero-arg call signature with a type parameter, so you can't call it the wrong way anymore:

const fOops = someFunction<string>() // error!
// Expected 1 arguments, but got 0.

Playground link to code

Related