40

In JavaScript, it's common to have a function that may be called in more than one way – e.g. with handful of positional arguments or a single options object or some combination of the two.

I've been trying to work out how to annotate this.

One way I tried was to annotate rest args as a union of various possible tuples:

type Arguments =
  | [string]
  | [number]
  | [string, number]
;

const foo = (...args: Arguments) => {
  let name: string;
  let age: number;

  // unpack args...
  if (args.length > 1) {
    name = args[0];
    age = args[1];
  } else if (typeof args[0] === 'string') {
    name = args[0];
    age = 0;
  } else {
    name = 'someone';
    age = args[1];
  }

  console.log(`${name} is ${age}`);
};

// any of these call signatures should be OK:
foo('fred');
foo('fred', 30);
foo(30);

The above snippet is contrived; I could probably just use (...args: Array<string | number>) in this example, but for more complex signatures (e.g. involving a typed options object that can be alone or with prior args) it would be useful to be able to define a precise, finite set of possible call signatures.

But the above doesn't type-check. You can see a bunch of confusing errors in tryflow.

I also tried typing the function itself as a union of separate entire function defs, but that didn't work either:

type FooFunction =
  | (string) => void
  | (number) => void
  | (string, number) => void
;

const foo: FooFunction = (...args) => {
  let name: string;
  let age: number;

  // unpack args...
  if (args.length > 1) {
    name = args[0];
    age = args[1];
  } else if (typeof args[0] === 'string') {
    name = args[0];
    age = 0;
  } else {
    name = 'someone';
    age = args[1];
  }

  console.log(`${name} is ${age}`);
};

// any of these call signatures should be OK:
foo('fred');
foo('fred', 30);
foo(30);

How should I approach type-annotating functions with multiple possible call signatures? (Or are multi-signatures considered an anti-pattern in Flow, and I just shouldn't be doing it at all – in which case, what is the recommended approach for interacting with third party libraries that do it?)

callum
  • 26,180
  • 30
  • 91
  • 142
  • Surprised method signature 'overloading' or argument union types don't work in Flowtype. – Martin May 03 '17 at 06:57
  • Overloading works. You can have trailing optional args, or typed rest args. And union types usually work, but for some reason not when used like this. – callum May 03 '17 at 07:37

3 Answers3

10

The errors you are seeing are a combination of a bug in your code and a bug in Flow.

Bug in your code

Let's start by fixing your bug. In the third else statement, you assign the wrong value to

  } else {
    name = 'someone';
    age = args[1]; // <-- Should be index 0
  }

Changing the array access to be the correct index removes two errors. I think we can both agree this is exactly what Flow is for, finding errors in your code.

Narrowing type

In order to get to the root cause of the issue, we can be more explicit in the area where the errors are so that we can more easily see what the problem is:

if (args.length > 1) {
  const args_tuple: [string, number] = args;
  name = args_tuple[0];
  age = args_tuple[1];
} else if (typeof args[0] === 'string') {

This is effectively the same as before but because we're very clear about what args[0] and args[1] should be at this point. This leaves us with a single error.

Bug in Flow

The remaining error is a bug in Flow: https://github.com/facebook/flow/issues/3564

bug: tuple type is not interacting with length assertions (.length >= 2 and [] | [number] | [number, number] type)

How to type overloaded functions

Flow is not great at dealing with variadics with different types, as in this case. Variadics are more for stuff like function sum(...args: Array<number>) where all the types are the same and there is no maximum arity.

Instead, you should be more explicit with your arguments, like so:

const foo = (name: string | number, age?: number) => {
  let real_name: string = 'someone';
  let real_age: number = 0;

  // unpack args...
  if (typeof name === 'number') {
    real_age = name;
  } else {
    real_name = name;
    real_age = age || 0;
  }

  console.log(`${real_name} is ${real_age}`);
};

// any of these call signatures should be OK:
foo('fred');
foo('fred', 30);
foo(30);

This causes no errors and I think is just easier to read for developers, too.

A better way

In another answer, Pavlo provided another solution that I like more than my own.

type Foo =
  & ((string | number) => void)
  & ((string, number) => void)

const foo: Foo = (name, age) => {...};

It solves the same problems in a much cleaner way, allowing you much more flexibility. By creating an intersection of multiple function types, you describe each different way of calling your function, allowing Flow to try each one based on how the function is called.

EugeneZ
  • 1,595
  • 10
  • 17
  • The question is about positional arguments, that is why the index is one within the else statement, as it is not desired for one paramater to serve two different purposes. – Wilco Bakker May 17 '17 at 06:54
  • @WilcoBakker I don't understand what you're saying. The index is incorrect, and will map to `undefined` when that branch is active, whereas the code in the other branches works as expected. Try this JS Fiddle: https://jsfiddle.net/w2eh7d7L/ As you can see, the third case (that triggers the else) does not work. So it's a bug. – EugeneZ May 17 '17 at 21:57
  • 1
    That is true, but doesn't positional arguments mean that the paramaters are fixed on position? And so age is fixed on position 2? (index 1) Doesn't matter though, but I thought the index 1 was put there intentionally. – Wilco Bakker May 18 '17 at 12:24
  • 1
    The parameters *are* fixed on position. That's why there's a bug. Age is fixed on index 1, but the else branch is only triggered in a scenario where index 1 is undefined and index 0 is not a string. I think you're confusing `age` as a parameter and age as a concept in this code. In the else branch, the parameter `age` is undefined, but the concept of age is being passed in index 0. (Well, that's the intent... in reality we've only guaranteed that less than 2 arguments are being passed and the first is not a string.) – EugeneZ May 19 '17 at 20:25
  • Is there a way to overload the return type? https://flow.org/try/#0PTAEAEDMBsHsHcBQiAuBPADgU1AMVrKALyKigBkoAFFQM4oBOAlgHYDmoAPqCwK4C2AIywMAlMQB8oes3ajSFanUas2AGh4DhYyZqEj5iAMawW9UJAIAuPAWLVaGluKJSA3gqaRq6bLG8sxEREoADkfPoMoeIMWCi8DIEAjADcCgqx8YlhSaFpAL5piJawVABEplhlomklVEk1xQTllWUaDSlAA – Gajus Nov 14 '18 at 23:30
  • @Gajus Not to my knowledge, I suspect because the return type is an output type. Intersecting the output types creates a type of `number & string` which cannot be satisfied. – EugeneZ Nov 16 '18 at 07:08
  • 1
    To be honest, the more I use Flow the more I feel the most correct answer in these cases is that you shouldn't overload the function. There's a reason most statically-typed languages do not allow overloading. Flow supports it because Javascript's type system lets you do this kind of thing, but if you really want to statically type your code, you should stop writing functions like this. Just write two functions and use the one you want. If they share code, write another function and call it. It's good enough for many languages. – EugeneZ Nov 16 '18 at 07:10
8

You can define multiple function signatures by joining them with &:

type Foo =
  & ((string | number) => void)
  & ((string, number) => void)

Try it.

Pavlo
  • 35,298
  • 12
  • 72
  • 105
  • Any explanation with this? Is this just how intersection works with functions? – Xunnamius Jun 15 '18 at 23:22
  • Is there a way to overload the return type? https://flow.org/try/#0PTAEAEDMBsHsHcBQiAuBPADgU1AMVrKALyKigBkoAFFQM4oBOAlgHYDmoAPqCwK4C2AIywMAlMQB8oes3ajSFanUas2AGh4DhYyZqEj5iAMawW9UJAIAuPAWLVaGluKJSA3gqaRq6bLG8sxEREoADkfPoMoeIMWCi8DIEAjADcCgqx8YlhSaFpAL5piJawVABEplhlomklVEk1xQTllWUaDSlAA – Gajus Nov 14 '18 at 23:30
1

Of the three possible workouts you gave, I've figured out how to make it work using an single options object, however because you require at least one object to be set, you need to define each possibility.

Like this:

type Arguments = 
    {|
        +name?: string,
        +age?: number
    |} |
    {|
        +name: string,
        +age?: number
    |} |
    {|
        +name?: string,
        +age: number
    |};

const foo = (args: Arguments) => {
  let name: string = args.name ? args.name : 'someone';
  let age: number = typeof args.age === 'number' && !isNaN(args.age) ? args.age : 0;
  console.log(`${name} is ${age}`);
}

// any of these call signatures are OK:
foo({ name: 'fred' });
foo({ name: 'fred', age: 30 });
foo({ age: 30 });

// fails
foo({});
Balthazar
  • 33,765
  • 10
  • 77
  • 104
Wilco Bakker
  • 497
  • 5
  • 16
  • 1
    Thanks for the answer, but this is just a complex way of annotating a single object; it doesn't address the problem of dealing with multiple arguments to present various legal call signatures. – callum May 02 '17 at 12:17
  • @callum Wait, I didn't noticed the index 1 in the else statement. I'll see what I can do. – Wilco Bakker May 02 '17 at 12:45
  • What if the return type of a function is dependent on its argument types? – Aluan Haddad May 09 '17 at 02:23