Passed function without arguments doesn't throw an error

Viewed 481

I'm using TypeScript 4.3.5. In the code, I need to pass a function and I need to check, if the function contains required arguments. This code doesn't throw a syntax error:

function functionA (call: (name: string) => boolean) {
  call("")
}

function functionB () {
  return true;
}

function functionC() {
  functionA(functionB); // this doesn't throw a syntax error
  functionB(""); // this throws a syntax error
}

I also tried this type definition:

type FunctionB = {
  (name: string): boolean
}

And nothing works. I expect from TypeScript to throw a syntax error when I call functionA(functionB) because functionB doesn't contain the expected argument. When I call functionB("") it throws an error.

Is there any solution how to reach a syntax error when I call functionA(functionB)?

3 Answers

No, this is intended behaviour and you cannot force TypeScript to throw a syntax error in such a scenario.

According to TypeScript's FAQ:

This is the expected and desired behavior. First, [...] [functionB] is a valid argument for [functionA] because it can safely ignore extra parameters.

Second, let's consider another case:

let items = [1, 2, 3];
items.forEach(arg => console.log(arg));

This is isomorphic to the example that "wanted" an error. At runtime, forEach invokes the given callback with three arguments (value, index, array), but most of the time the callback only uses one or two of the arguments. This is a very common JavaScript pattern and it would be burdensome to have to explicitly declare unused parameters.

But forEach should just mark its parameters as optional! e.g. forEach(callback: (element?: T, index?: number, array?: T[]))

This is not what an optional callback parameter means. Function signatures are always read from the caller's perspective. If forEach declared that its callback parameters were optional, the meaning of that is "forEach might call the callback with 0 arguments".

The meaning of an optional callback parameter is this:

// Invoke the provided function with 0 or 1 argument
function maybeCallWithArg(callback: (x?: number) => void) {
    if (Math.random() > 0.5) {
        callback();
    } else {
        callback(42);
    } 
} 

forEach always provides all three arguments to its callback. You don't have to check for the index argument to be undefined - it's always there; it's not optional.

As explained in @Liad's answer, this pattern is used across many JavaScript functions, native or otherwise (think Array#forEach, Array#map, Array#reduce), and requiring users of the functions to declare unused parameters would be "burdensome" at best, as mentioned in the FAQ entry.

Therefore, functionB is assignable to call as long as it shares its return type and does not add any more parameters, but declaring less parameters will not throw an error.

As for whether it's possible to force the TypeScript compiler to throw a syntax error, this is also addressed at the bottom of the aforementioned FAQ entry:

There is currently not a way in TypeScript to indicate that a callback parameter must be present. Note that this sort of enforcement wouldn't ever directly fix a bug. In other words, in a hypothetical world where forEach callbacks were required to accept a minimum of one argument, you'd have this code:

[1, 2, 3].forEach(() => console.log("just counting"));
             //   ~~ Error, not enough arguments?

which would be "fixed", but not made any more correct, by adding a parameter:

[1, 2, 3].forEach(x => console.log("just counting"));
               // OK, but doesn't do anything different at all

As far as i know this is not possible, and there's a reason for that, let me explain:

say you want to use the array map function like this:

const double: number[] = [1,5,3,4].map((num) => num * 2)

but the map function requires 3 arguments (typescript 4.3.5 declaration):

map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];

Then if what you suggested would apply, the code i wrote above would not work and i will always have to supply all three arguments like this:

const double: number[] = [1,5,3,4].map((num, i, arr) => num * 2)

which would be quite tedious...

this is also the reason why the following expression resolves to true:

type ArgFunc = (arg: string) => boolean;
type NoArgFunc = () => boolean;

type DoesExtend = NoArgFunc extends ArgFunc ? true : false; // true

but the opposite resolves to false:

type DoesExtend = ArgFunc extends NoArgFunc ? true : false; // false

Ts Playground

Typescript Conditional Operator to the rescue... somewhat?

type FnStrongTypedOrNever<T extends (arg: any) => any, TConstraint extends any[]> =
    [...Parameters<T>, ReturnType<T>] extends TConstraint ? T : never;

function functionA<T extends (arg: any) => any>(call: FnStrongTypedOrNever<T, [string, boolean]>) {
  call("")
}

function functionB() {
  return true;
}

function functionD(name: string) {
  return true;
}

function functionC() {
  functionA(functionD); // this doesn't throw a syntax error
  functionA(functionB); // this throws a syntax error
  functionB(""); // this throws a syntax error
}

A quick & dirty solution using typescript's extends syntax. With some additional type magic.

The type FnStrongTypedOrNever<T, TConstraint> returns never if the function does not respect the constraint.

So, at the end of the day... the error you receive is Argument of type '() => boolean' is not assignable to parameter of type 'never'.

At the moment this is the best you can do with TS (as far as I know)

EDIT:

Updated solution

This one has a better error message via TConstraint & '' usage instead of never

Now you can clearly see that the parameter list is not matching.

This is still a type of hack, and as far as I know you cannot change the ambient types to make this globally work.

Related