Andru
Andru

Reputation: 6184

How can I implement a function with multiple call signatures?

I want to constrain my function.

Given a certain literal type as input, only a certain template literal type should be returned as output for two of the input cases.

For example given the input "rain" I want the output "umbrella_foo".

To address this I created this overloaded function:

type Options = "rain" | "sun" | "wind" | "snow";

type OverloadedFunction = {
  (option: 'rain'): `umbrella_${string}`;
  (option: 'sun'): `shorts_${string}`;
  (option: Options): string;
};

const myFunctionFoo: OverloadedFunction = (options: Options) => {
  if (options === "rain") {
    return 'umbrella_foo';
  }
  if (options === "sun") {
    return 'shorts_foo';
  }
  return 'anything'
}

OverloadedFunction seems alright but unfortunately hovering over myFunctionFoo I see the TypeScript error:

Type '(options: Options) => "umbrella_foo" | "shorts_foo" | "anything"' is not assignable to type 'OverloadedFunction'.
  Type '"umbrella_foo" | "shorts_foo" | "anything"' is not assignable to type '`umbrella_${string}`'.
    Type '"shorts_foo"' is not assignable to type '`umbrella_${string}`'.(2322)

Why does TypeScript want to assign "shorts_foo" to something of type umbrella_${string}?

Somehow, TypeScript ignores the if/else conditional. It's no different with a switch/case block.

TypeScript Playground of the above code.

Upvotes: 2

Views: 433

Answers (1)

jcalz
jcalz

Reputation: 327634

This doesn't have to do with string literal types or template literal types. Rather, it's about how the compiler checks assignability for overloaded function types which have multiple call signatures. See microsoft/TypeScript#33482 for a canonical answer.


To make the issue clear, let me rewrite your example without any literals or template literals:

type OverloadedFunction = {
  (x: string): string;
  (x: number): number;
};

const arrowFn: OverloadedFunction // error!
  //  ~~~~~~~ <-- Type '(x: string | number) => string | number' 
  // is not assignable to type 'OverloadedFunction'.
  = (x: string | number) => x;

Here we have the same problem. The compiler is not able to see that the initializer for arrowFn conforms to OverloadedFunction. The value (x: string | number) => x is inferred to be of type (x: string | number) => string | number, which is too wide to be assignable to OverloadedFunction.

Indeed, if all you knew about a function was that it accepts string | number and outputs string | number, you wouldn't be safe using it where an OverloadedFunction is needed. For example:

const sameProblem: OverloadedFunction // error!
  //  ~~~~~~~~~~~ <-- Type '(x: string | number) => string | number' 
  // is not assignable to type 'OverloadedFunction'.
  = (x: string | number) => Math.random() ? String() : Number();

In this case the error is a good one, right? The function just randomly returns a string or a number without consulting the input value at all, which is not what an OverloadedFunction does.

According to a comment on a duplicate of microsoft/TypeScript#33482,

TypeScript can't analyze the function body to determine which codepaths get taken during certain overload cases, so it can only look at the overall inferred return type, which is indeed wider than the specified return type. It isn't possible to write this assignment pattern without a type assertion in at least one location.

Such analysis is not impossible to implement, but it would make compiler performance worse; the time to type check functions would apparently scale by the square of the number of call signatures (cf this comment on a related issue in GitHub), and so the cost of doing so is greater than the benefit.

If you want to assign a function expression to an overloaded function type and you run into this problem, you'll need to use a type assertion to convince the compiler that everything is fine:

const arrowFnAssert
  = ((x: string | number) => x) as OverloadedFunction;

But do note that arrow functions are not generally the way people implement overloads. As you've seen, the compiler tends to give false positives because it is too cautious to allow what turns out to be a safe implementation.

Instead, people generally implement overloads via function statements, which are checked more loosely:

// call signatures
function statementFn(x: string): string;
function statementFn(x: number): number;

// implementation
function statementFn(x: string | number) {
  return x; // no error
}

const reassigned: OverloadedFunction =
  statementFn; // okay

Now there's no error. This doesn't actually mean it's checked thoroughly, though. That would still be too expensive for the compiler. It's just that for overloaded function statements, the compiler errs on the side of false negatives, and so it fails to catch errors like this:

function oops(x: string): string;
function oops(x: number): number;
function oops(x: string | number) {
  return Math.random() ? String() : Number(); // no error

For that issue, see microsoft/TypeScript#13235, where again it is explained that the "proper" check is prohibitively expensive.


Either way, the compiler is unable to really determine what is and is not a safe implementation for an overloaded function. You'll have to use a type assertion or an overloaded function statement if you want to proceed without a compiler error, and in either case you need to be careful to do the correct check yourself because the compiler is unable to.

Playground link to code

Upvotes: 2

Related Questions