Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to annotate a function with multiple possible call signatures in Flow?

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?)

like image 439
callum Avatar asked Apr 28 '17 19:04

callum


Video Answer


2 Answers

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.

like image 169
EugeneZ Avatar answered Oct 26 '22 21:10

EugeneZ


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

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

Try it.

like image 32
Pavlo Avatar answered Oct 26 '22 20:10

Pavlo