5

I defined an applyDamage method to accept number or string, but implemented it with only with a number argument. When I call this method using base class, I have incorrect behavior. Why doesn't TypeScript show an error?

interface Character {
    applyDamage(value : number): number;
}
interface Humanoid extends Character {
    hp:number;
    applyDamage(value: number|string): number
}
class Monster implements Humanoid {
    hp:number = 10;
    applyDamage(v: number) {
        this.hp -= v;
        return v;
    }
}
const monster:Humanoid = new Monster();
monster.applyDamage("hello");
console.log(monster.hp); // <-- NaN
3
  • are you possibly trying to define an overload (typescriptlang.org/docs/handbook/2/…) for the applyDamage method? Commented Feb 1, 2022 at 14:40
  • That's indeed somewhat curious since one would think typescript should warn you if you 'overwrite' the type of the property in the extended interface. Since this isn't happening however, you don't get a warning at all. If you do const monster:Humanoid = new Character();, you will get an error when trying to call applyDamage with a string Commented Feb 1, 2022 at 14:42
  • 1
    @ rmalizia44 - We don't fold answers into the question here on SO. The question box is for the question, and answer boxes are for answers. Titian's answer will rise to the top of the answer list as it gets voted on. Commented Feb 1, 2022 at 17:05

3 Answers 3

7

Unfortunately this is one of the loopholes in type safety caused by the fact that method parameters are checked bi-variantly. This means that as long as there is a relationship between the function parameters it doesn't matter in which direction that relationship is.

Here's a link to the related TypeScript handbook section: Function Parameter Bivariance

The reasoning for this is explained in the PR that introduces strict function types (i.e. contravariant parameter types for function signatures) and is basically that if methods were checked contravariantly it would result in most generic types being invariant (so you couldn't assign Array<Cat> to Array<Animal>).

One solution is to avoid method signatures and use function signatures wherever possible:

interface Character {
    applyDamage: (value : number) => number;
}
interface Humanoid extends Character {
    hp:number;
    applyDamage: (value: number|string) => number
}
class Monster implements Humanoid {
    hp:number = 10;
    applyDamage(v: number) { // error
        this.hp -= v;
        return v;
    }
}
const monster:Humanoid = new Monster(); // error
monster.applyDamage("hello");
console.log(monster.hp); // <-- NaN

Playground Link

If you want to learn more about variance, you can watch my presentation on it here.

Sign up to request clarification or add additional context in comments.

4 Comments

"...as long as there is a relationship between the function parameters it doesn't matter in which direction that relationship is" Can you elaborate on that statement? See: tsplay.dev/we4QdW
@jsejcksn In your sample, those are function signatures, so they are checked contravariantly. If you change it to a signature originating in a method you get that bivarinat behavior tsplay.dev/wgXQ1N . So AssignableTo<NumberOnly, NumberOrString>; and AssignableTo<StringOnly, NumberOrString> will be ok too. This is what I mean when I say it doesn't matter in which direction. If the first param would be boolean that would be an error as there is no relation between the types of the parameters.
@TitianCernicova-Dragomir Thanks. I updated your answer with a link to the related section in the TS handbook.
A mutable array of Cat's isn't a mutable array of Animals. Wow, typescript made the wrong choice.
1

As others have pointed out: an overload would work... but the way that I read your code, it seems like you only want to implement a subtype of the union parameter, so I think generics are the right answer to your issue. By also providing a default type parameter for the generics, they can be used more ergonomically.

TS Playground

interface Character<T extends string | number = number> {
  applyDamage (value: T): number;
}

interface Humanoid<T extends string | number = string | number> extends Character<T> {
  hp: number;
}

class Monster implements Humanoid<number> {
  hp: number = 10;

  applyDamage(v: number) {
    this.hp -= v;
    return v;
  }
}


////////// Use:
const monster: Humanoid<number> = new Monster();

// You could also just write it this way:
// const monster = new Monster();

monster.applyDamage("hello"); /*
                    ^^^^^^^
Argument of type 'string' is not assignable to parameter of type 'number'.(2345) */

console.log(monster.hp); //=> NaN

1 Comment

There are lots of ways to make the code work. The way I read the question, though, it's about why the way they did it doesn't get flagged as an error by TypeScript, not how to do it differently. (That's certainly the question I'd like to see the answer for.)
0

You defined the signature of the method in Humanoid for applyDamage to take an input value of string | number;

TS just checks that your implementation for Monster class has the appropriate signature and returns the defined type. It's up to you to write a method body that is safe.

It seems that maybe you want to use a overload. https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads

3 Comments

But TypeScript doesn't appear to have checked that the implementation has the appropriate signature. The signature of the implementation is v: number, not v: number | string, but Humanoid's applyDamage takes number | string. Monster can't be a valid Humanoid if it only accepts number. (Interestingly, it does complain if you use, say, boolean. So it's doing something.)
The interface is saying that the implementation requires a method that takes the union type of string | number. Maybe a deeper read of the union type docs would be more helpful. (typescriptlang.org/docs/handbook/2/…) I did upvote the question, in hopes that a smarter coder can clarify. I am failing to think of a better way to explain.
(I'm not the OP) I understand union types. My point is that the implementation doesn't implement what the interface requires. The interface requires (number | string) => number, the implementation only provides (number) => number. A Monster is not a valid Humanoid if it doesn't accept string in addition to number for that method. I'm hoping someone like Titian (I've pinged him) or jcalz or someone can explain what's going on here. :-)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.