SystemParadox
SystemParadox

Reputation: 8647

Typescript overriding overloaded method in subclass - incorrect type

Typescript playground link

I'm attempting to extend an overloaded method in a subclass, but Typescript doesn't seem to be using the right argument types when calling the original function:

declare class Creep {
    move(direction: number): void;
    move(target: string): void;
}

const _originalMove = Creep.prototype.move;

class CreepExtra extends Creep {
    move(direction: number): void;
    move(target: string): void;
    move(direction: number | string) {
        if (typeof direction === 'string') {
            return _originalMove.call(this, direction);
        }
        return _originalMove.call(this, direction);
    }
}

Results in this error (for the direction argument of the last call):

Argument of type 'number' is not assignable to parameter of type 'string'.(2345)

Am I doing something wrong or is this a bug in typescript?

Upvotes: 4

Views: 764

Answers (1)

jcalz
jcalz

Reputation: 329343

This is a limitation of TypeScript. The support for strongly-typed Function.prototype.call() methods that was introduced in TypeScript 3.2 doesn't work well for generic or overloaded functions. See this comment in microsoft/TypeScript#27028, the pull request that implemented this support.

When you directly call an overloaded function, the compiler will select the most appropriate call signature. But when you start dealing with overloaded function types, the compiler essentially gives up and just picks one of the call signatures without paying attention to whether it's appropriate for the eventual use case. This is usually the last call signature (although sometimes it's the first, I guess?) It's not well documented; see microsoft/TypeScript#43187 for information.

In your case that means that the Function.prototype.call() on _originalMove behaves as if it has just the last call signature, of type (target: string) => void;. And that's fine for one of your calls but not fine for the other. Oh well.


A workaround here would be for you to explicitly widen the type of _originalMove to the single relevant call signature before calling call() on it. That is, the following is considered a safe assignment:

  const om: (direction: number) => void = _originalMove;

because _originalMove has both call signatures. And at that point, om.call() will allow the second parameter to be of type number:

  return om.call(this, direction);

And everything just works:

class CreepExtra extends Creep {
  move(direction: number): void;
  move(target: string): void;
  move(direction: number | string) {
    if (typeof direction === 'string') {
      return _originalMove.call(this, direction);
    }
    const om: (direction: number) => void = _originalMove;
    return om.call(this, direction); // okay
  }
}

Hooray!


Note well:

In the example shown here your overloaded method has two call signatures differing only the the type of the single function parameter; such call signatures can be easily unified via union types:

declare class Creep {
  move(directionOrTarget: number | string): void;
}

const _originalMove = Creep.prototype.move;

class CreepExtra extends Creep {
  move(targetOrDirection: number | string) {
    return _originalMove.call(this, targetOrDirection);
  }
}

Since overloads and type inference don't really play nicely together, as shown in the first part of this answer, if you have the opportunity to remove overloads without sacrificing anything else, then you should take it. Presumably your actual use case isn't as simple as the example code shown here, but it's a good exercise for everyone to check whether the benefits of overloads outweigh their disadvantages.

Playground link to code

Upvotes: 2

Related Questions