Can TypeScript return the subclass if a function has the superclass as its return type?

Viewed 646

Problem

Error: Property 'attach' does not exist on type 'Component'. How can I return an arbitrary subclass stored in a custom dictionary type, and use a method that only exists on the subclass if the dictionary type has a return value of the superclass?

Context

I have a Component class that has many subclasses. My GameActor class can have Components attached to it. An attached component is stored in the ComponentContainer member which is a custom type, and can be any of the subclasses of Component. E.g. MovementComponent, HealthComponent, EventComponent, etc. can all be stored in ComponentContainer.

When I retrieve an attached Component, I get the "Property does not exist" error above when I try and call a method from the retrieved Component. If I open up the browser's dev tools, I can see my log of the returned type looks like it is, in fact, the subclass type

ComponentContainer type definition

//globals.d.ts
// ComponentContainer type definition
// populated data structure will look like:
// {
//   "EventComponent": EventComponent,
//   "MovementComponent": MovementComponent,
//   "FooComponent": FooComponent
//   // etc...
// }
type ComponentContainer = {
  [componentName: string]: Component;
}

The GameActor has a bunch of methods to add, remove, get, and list the attached components.


//GameActor class that can have Components attached to it
export abstract class GameActor extends GameObject implements IGameActor {

  protected components: ComponentContainer;

  constructor() {
    this.components = {};
  }

  public getComponent(key: string): Component|null {
      return this.components[key];
  }
}

// Player subclass of GameActor
export class Player extends GameActor implements IPlayer {
  //Player class code here...
}

//Component superclass
export abstract class Component {
  protected typeId: string;

  public getTypeId(): string {
    return this.typeId;
  }
}

//EventComponent subclass
export class EventComponent extends Component implements IEventComponent {

  public attach(event: Event|CustomEvent): void {
    //Attach code here...
  }
}

Now somewhere else in the code I want to do the following:

this.getComponent("EventComponent").attach(PlayerDeathEvent.create(this));

at which point I receive the error. If I log out the following code, it appears both are of type EventComponent.

let ec = this.Player.getComponent("EventComponent");
let t = new EventComponent(); 

browser console log

I expect .attach to not throw an error because the compiler knows the component is of type EventComponent.

1 Answers

The issue here is that the compiler doesn't have the information needed to narrow the results of this.getComponent("EventComponent") from Component | null to EventComponent. The method getComponent() is declared to take a string and return a Component | null, so that's all the compiler really knows.

It's true that the compiler does a certain amount of control flow analysis where it refines the types of values to more specific types that how they're declared, based on how they are used... but this only happens in very specific circumstances and is by no means perfect. The compiler cannot look at arbitrary code and figure out what exactly will happen at runtime before it runs. Well, technically nobody can do that. But yes, there are plenty of instances where what is obvious to a human being is not known to the compiler. Here's a simplified example:

function hmm(x: number) {
    return (x >= 0) ? x : "negative";
}

console.log(hmm(4).toFixed()); // error!
// --------------> ~~~~~~~
// "toFixed" does not exist on number | "negative"

The compiler only knows that hmm() takes a number and returns a number | "negative". It is obvious to a human being that hmm(4) will return a number and thus will have a toFixed() method, and sure enough at runtime the code runs with no error. But the compiler doesn't know that hmm(4) will end up evaluating 4 >= 0, and it doesn't know that 4 >= 0 will end up returning true, and so it doesn't know that hmm(4) will not return "negative", which means it doesn't know that hmm(4) will have a toFixed() method, which means... compiler error.


Instead of expecting the compiler to figure out what will happen from the flow of the code, you will have better results if you give the compiler the information explicitly via stronger types. My recommendation is to strengthen your ComponentContainer type to represent the key-value mapping you implied in your comments, and make getComponent() a generic method where the key parameter is of generic type K from among keyof ComponentContainer, and it returns a value of type ComponentContainer[K] (which is what you get when you look up the K property of a ComponentContainer object). Like this:

type ComponentContainer = {
    EventComponent: EventComponent;
    // MovementComponent: MovementComponent,
    // FooComponent: FooComponent
    // etc
}

abstract class GameActor {

    protected components: ComponentContainer;

    constructor() {
        // better actually initialize this properly
        this.components = {
            EventComponent: new EventComponent()
            // MovementComponent: new MovementComponent();
            // FooComponent: new FooComponent();
            // etc
        }
    }

    public getComponent<K extends keyof ComponentContainer>(key: K): ComponentContainer[K] {
        return this.components[key];
    }
}

Now when you call your method it should work the way you expect:

// Player subclass of GameActor
class Player extends GameActor {
    bloop() {
        this.getComponent("EventComponent").attach(PlayerDeathEvent.create(this)); // okay

    }
}

Okay, hope that helps; good luck!

Link to code

Related