tresor13
tresor13

Reputation: 117

Is it possible to type function with dynamic number of parameters?

I'm now trying to type entity called Operators. It looks like this and as you can see has many commons. But there are some differences in values, for example callback function by count key. Sometimes it expects 1 number, sometimes 2.

const operatorEntities: Record<OperatorsKeys, OperatorValue> = {
  sin: {
    textForInput: "sin",
    onClick,
    type: UNARY_OPERATOR_TYPE,
    innerHtml: "sin",
    count: Math.sin,
    level: 3,
    className: "sinus btn btn-outline-primary",
  },
  cos: {
    textForInput: "cos",
    onClick,
    type: UNARY_OPERATOR_TYPE,
    innerHtml: "cos",
    level: 3,
    count: Math.cos,
    className: "sinus btn btn-outline-primary",
  },
  "!": {
    textForInput: "!",
    onClick,
    type: UNARY_OPERATOR_TYPE,
    innerHtml: "!n",
    level: 3,
    count: function factorialize(num): number {
      if (num < 0) return -1;
      else if (num == 0) return 1;
      else {
        return num * factorialize(num - 1);
      }
    },
    className: "factorial btn btn-outline-primary",
  },
  "+": {
    textForInput: "+",
    onClick,
    type: BINARY_OPERATOR_TYPE,
    innerHtml: "+",
    level: 1,
    count: (left, right) => left + right,
    className: " plus btn btn-outline-primary",
  },
  "-": {
    textForInput: "-",
    onClick,
    type: BINARY_OPERATOR_TYPE,
    innerHtml: "-",
    level: 1,
    count: (left, right) => left - right,
    className: "minus btn btn-outline-primary",
  }
}

So if I make type of the count function this way

type CountFunction = (operand1: number, operand2?: number) => number;

Then it will be the mistake in such a function

count: (a, b) => a + b;

Because b argument may be undefined. In this case I wanted to ask if there is any way to type function with 2 arguments and the second one should be optional? Or i just have to make 2 types for function with one and two arguments?

Upvotes: 1

Views: 269

Answers (1)

jcalz
jcalz

Reputation: 328132

As you note, it's not safe to assign a function of type (o1: number, o2: number) => number to a variable of type (o1: number, o2?: number) => number, because such a function expects o2 to be a number and might explode when unknowingly dereferencing an undefined at runtime. According to the rules for function subtyping, (o1: number, o2: number) => number is a supertype of (o1: number) => number, so your CountFunction would just be

type CountFunction = (o1: number, o2: number) => number;

That might be surprising, but the TypeScript compiler takes the view that a function of the form (o1: number) => number will probably just ignore a second argument, and so it's harmless to allow the assignment. So you can't make o2 optional, but you can omit it altogether. Such confusion between optional parameters and shortened parameter lists is common enough that it is explicitly discussed in the TypeScript Handbook.


So that's the answer to your question as asked, but it's not what you want, is it? It will let your assignments succeed:

enum OperatorType {
    UNARY,
    BINARY
}
type OperatorThing = {
    type: OperatorType,
    count: (x: number, y: number) => number
}
const operatorEntities: Record<string, OperatorThing> = {
    sin: { type: OperatorType.UNARY, count: Math.sin, },
    cos: { type: OperatorType.UNARY, count: Math.cos, },
    "!": {
        type: OperatorType.UNARY, count: function factorialize(num): number {
            if (num < 0) return -1;
            else if (num == 0) return 1;
            else {
                return num * factorialize(num - 1);
            }
        },
    },
    "+": { type: OperatorType.BINARY, count: (left, right) => left + right, },
    "-": { type: OperatorType.BINARY, count: (left, right) => left - right, }
}

but when you try to actually call those count methods you'll find that the compiler now requires you to pass in a second argument no matter what, even if you check the type property:

Object.keys(operatorEntities).forEach(k => {
    const op = operatorEntities[k];
    if (op.type === OperatorType.UNARY) {
        op.count(1); // error!
        // ~~~~~~~~ <-- expected 2 arguments but got 1
    } else {
        op.count(2, 3);
    }
})

So it isn't very helpful to have a function type that represents the union of the possible function types you have. You want a way to discriminate among them.


My suggestion here is to keep your separate one- and two-argument function types, and define OperatorThing as a discriminated union where the type property acts as a discriminant that can be used to determine which function type you have:

type OperatorThing =
    { type: OperatorType.UNARY, count: (o1: number) => number } |
    { type: OperatorType.BINARY, count: (o1: number, o2: number) => number }

The assignment of operatorEntities still succeeds, but now you can call functions with the right number of arguments:

Object.keys(operatorEntities).forEach(k => {
    const op = operatorEntities[k];
    if (op.type === OperatorType.UNARY) {
        op.count(1); // okay
    } else {
        op.count(2, 3); // okay
    }
})

Playground link to code

Upvotes: 1

Related Questions