Skip to main content

Strict Checking of Function Call Arity

One of Flow's original goals was to be able to understand idiomatic JavaScript. In JavaScript, you can call a function with more arguments than the function expects. Therefore, Flow never complained about calling a function with extraneous arguments.

We are changing this behavior.

What is arity?

A function's arity is the number of arguments it expects. Since some functions have optional parameters and some use rest parameters, we can define the minimum arity as the smallest number of arguments it expects and the maximum arity as the largest number of arguments it expects.

function no_args() {} // arity of 0
function two_args(a, b) {} // arity of 2
function optional_args(a, b?) {} // min arity of 1, max arity of 2
function many_args(a, ...rest) {} // min arity of 1, no max arity

Motivation

Consider the following code:

function add(a, b) { return a + b; }
const sum = add(1, 1, 1, 1);

The author apparently thought the add() function adds up all its arguments, and that sum will have the value 4. However, only the first two arguments are summed, and sum actually will have the value 2. This is obviously a bug, so why doesn't JavaScript or Flow complain?

And while the error in the above example is easy to see, in real code it's often a lot harder to notice. For example, what is the value of total here:

const total = parseInt("10", 2) + parseFloat("10.1", 2);

"10" in base 2 is 2 in decimal and "10.1" in base 2 is 2.5 in decimal. So the author probably thought that total would be 4.5. However, the correct answer is 12.1. parseInt("10", 2) does evaluates to 2, as expected. However, parseFloat("10.1", 2) evaluates to 10.1. parseFloat() only takes a single argument. The second argument is ignored!

Why JavaScript allows extraneous arguments

At this point, you might feel like this is just an example of JavaScript making terrible life decisions. However, this behavior is very convenient in a bunch of situations!

Callbacks

If you couldn't call a function with more arguments than it handles, then mapping over an array would look like

const doubled_arr = [1, 2, 3].map((element, index, arr) => element * 2);

When you call Array.prototype.map, you pass in a callback. For each element in the array, that callback is invoked and passed 3 arguments:

  1. The element
  2. The index of the element
  3. The array over which you're mapping

However, your callback often only needs to reference the first argument: the element. It's really nice that you can write

const doubled_arr = [1, 2, 3].map(element => element * 2);

Stubbing

Sometimes I come across code like this

let log = () => {};
if (DEBUG) {
log = (message) => console.log(message);
}
log("Hello world");

The idea is that in a development environment, calling log() will output a message, but in production it does nothing. Since you can call a function with more arguments than it expects, it is easy to stub out log() in production.

Variadic functions using arguments

A variadic function is a function that can take an indefinite number of arguments. The old-school way to write variadic functions in JavaScript is by using arguments. For example

function sum_all() {
let ret = 0;
for (let i = 0; i < arguments.length; i++) { ret += arguments[i]; }
return ret;
}
const total = sum_all(1, 2, 3); // returns 6

For all intents and purposes, sum_all appears like it takes no arguments. So even though it appears to have an arity of 0, it is convenient that we can call it with more arguments.

Changes to Flow

We think we have found a compromise which catches the motivating bugs without breaking the convenience of JavaScript.

Calling a function

If a function has a maximum arity of N, then Flow will start complaining if you call it with more than N arguments.

test:1
1: const num = parseFloat("10.5", 2);
^ unused function argument
19: declare function parseFloat(string: mixed): number;
^^^^^^^^^^^^^^^^^^^^^^^ function type expects no more than 1 argument. See lib: <BUILTINS>/core.js:19

Function subtyping

Flow will not change its function subtyping behavior. A function with a smaller maximum arity is still a subtype of a function with a larger maximum arity. This allows callbacks to still work as before.

class Array<T> {
...
map<U>(callbackfn: (value: T, index: number, array: Array<T>) => U, thisArg?: any): Array<U>;
...
}
const arr = [1,2,3].map(() => 4); // No error, evaluates to [4,4,4]

In this example, () => number is a subtype of (number, number, Array<number>) => number.

Stubbing and variadic functions

This will, unfortunately, cause Flow to complain about stubs and variadic functions which are written using arguments. However, you can fix these by using rest parameters

let log (...rest) => {};

function sum_all(...rest) {
let ret = 0;
for (let i = 0; i < rest.length; i++) { ret += rest[i]; }
return ret;
}

Rollout plan

Flow v0.46.0 will ship with strict function call arity turned off by default. It can be enabled via your .flowconfig with the flag

experimental.strict_call_arity=true

Flow v0.47.0 will ship with strict function call arity turned on and the experimental.strict_call_arity flag will be removed.

Why turn this on over two releases?

This decouples the switch to strict checking of function call arity from the release.

Why not keep the experimental.strict_call_arity flag?

This is a pretty core change. If we kept both behaviors, we'd have to test that everything works with and without this change. As we add more flags, the number of combinations grows exponentially, and Flow's behavior gets harder to reason about. For this reason, we're choosing only one behavior: strict checking of function call arity.

What do you think?

This change was motivated by feedback from Flow users. We really appreciate all the members of our community who take the time to share their feedback with us. This feedback is invaluable and helps us make Flow better, so please keep it coming!