Home > Software design >  Recursion breaks type checking on overloaded functions
Recursion breaks type checking on overloaded functions

Time:10-09

Given the following very similar functions:

function transform1 <T extends string>(value: T): T;
function transform1 <T extends string>(value: T[]): T[]; // error, can't return 0
function transform1 <T extends string>(value: T | T[]) {
  if (Array.isArray(value)) {
    return 0;
  } else {
    return value[0];
  }
}

function transform2 <T extends string>(value: T): T;
function transform2 <T extends string>(value: T[]): T[];
function transform2 <T extends string>(value: T | T[]) {
  if (Array.isArray(value)) {
    return 0; // ok somehow?
  } else {
    return transform2([value])[0];
  }
}

Why is it that for the second one, I am not warned about returning 0? The only difference is that in the second one I am calling transform2 again (recursion). Ignore the fact that this call will break at runtime since 0 will be returned and 0[0] is undefined.

Why does recursion stop TypeScript from checking it? I'm forced to explicitly annotate the function return type here:

function transform2 <T extends string>(value: T | T[]): T | T[] {

Playground

CodePudding user response:

Type checking for overloaded function statements is a bit weird and not well documented. The set of call signatures is compared to the implementation signature, and if the compiler decides that they aren't sufficiently "related" to each other, then it will issue an error (albeit not a very helpful error, see ms/TS#48186).

In your example code, the compiler has to infer the implementation return type from the implementation body. For transform1 it infers string | 0 (which you can verify by copying the implementation to a new function and seeing the inferred return type), since value[0] is getting a character of a string, I think. For transform2 it infers T | 0 (again, verifiable by copying), since value is narrowed to T, and transform2([value]) returns T[], and indexing into it is T.

That means, from the type checker's standpoint, these are your functions:

function transform1<T extends string>(value: T): T;
function transform1<T extends string>(value: T[]): T[] // error!
function transform1<T extends string>(value: T | T[]): string | 0 {
  throw new Error()
}

function transform2<T extends string>(value: T): T;
function transform2<T extends string>(value: T[]): T[];
function transform2<T extends string>(value: T | T[]): T | 0 {
  throw new Error()
}

(And so recursion is a bit of a red herring here.)

The first one errors because string | 0 is not seen as properly "related" to T | T[], while the second one is acceptable because T | 0 is seen as properly "related" to T | T[]. I don't really know why; for the first one it seems obvious that T[] cannot possibly be a string or 0 since T extends string. For the second one it seems equally impossible that T[] cannot possibly be a string or 0 for the same reason. All that means is that the compiler isn't really using such logic to allow/disallow call signatures. I guess that the presence of the generic causes the compiler to be more lenient and not bother checking if T and T[] are compatible or not.

In looking for an authoritative answer I rediscovered a somewhat related issue at microsoft/TypeScript#44661, where, when asked about the rules for comparing overload call signatures with implementations, @RyanCavanaugh said:

The rules are a mess of backcompat that no one wanted to write down.

Basically the idea is to detect totally wrong situations and not too much else extra, keeping in mind the limitation that we're analyzing the function body opaquely.

So that's the closest I can get to an answer: string | 0 is "totally wrong" for T[], whereas T | 0 is, apparently, not "totally wrong", at least to the extent the compiler's cursory check for "wrongness" can detect.

Playground link to code

  • Related