Typescript Parent Class type guard to Child Classes

Viewed 10812

I have a an abstract class FooObject. Only two children ever inherit FooObject, lets call them ThisFooObject and ThatFooObject.

I'm looking for a performant way to turn a FooObject into whichever child it is. In this case, that means not using instanceof - which performs at <10% the speed of a boolean lookup.

The way I've investigated is something like this:

FooObject.ts

import ThisObject from "./ThisObject";
import ThatObject from "./ThatObject";

abstract class FooObject {

  isThisObject() : this is ThisFooObject {
    // Either check for ThisFooObject properties, or override this
    // in the ThisFooObject class to always return true.
    return duckTypingTest();
  }

  // ditto here
  isThatObject() : this is ThatFooObject {}
}

I understand that this is not pretty. It's not a scalable approach if I have more children of FooObject - and it requires the parent to know about the children in a kinda encapsulation breaking way. But in my specific case, I'm willing to put up with this if it avoids me having to do 'instanceof'.

On the bright side, when I have a reference to a FooObject, and I want to something when it's a ThatFooObject, I can do something like:

fooObjects.forEach( foo => {
  // Foo currently typed as a FooObject
  if(foo.isThatObject()){
    // Foo now typed as a ThatFooObject
    foo.magic+=10
  }
});

This is all works - with one little caveat - I have to be careful to get the import order correct. IE, in my main.ts entry point, do something like

import 'ThatFooObject';
import 'FooObject';

But other than that, I'm fairly well pleased with this pattern. The issue comes when I try this with more than one child. I start to get issues with circular dependencies throwing things off, and errors like class extends value undefined is not a constructor or null.

I can't figure out how to make this work. Am I just barking up the wrong tree, and I should do this a much simpler way?

2 Answers

How about something like this, which doesn't require the classes to know about each other:

The plan is to give each concrete constructor a string property called typeId which we will use to distinguish among subclasses. Here's the shape of a FooObject constructor:

interface FooObjectConstructor<T extends FooObject> {
  new(...args: any[]): T
  typeId: string;
}

Now we can define the abstract FooObject class:

abstract class FooObject {

  // note 1
  // abstract static readonly typeId: string;  

  // note 2
  readonly typeId = (this.constructor as FooObjectConstructor<this>).typeId; 

  // note 3
  instanceOf<T extends FooObject>(ctor: FooObjectConstructor<T>): this is T {
    return this.typeId === ctor.typeId
  }
}
  1. TypeScript doesn't allow abstract static properties, so I can't say abstract static typeId and have the compiler warn me if my subclass doesn't implement it. So we just have to remember to do it ourselves.

  2. We will copy the static typeId to each instance upon creation, so that the instance can access this.typeId instead of this.constructor.typeId. I'm not sure if this helps performance much, but you can leave this out and replace references to this.typeId with this.constructor.typeId everywhere if you want.

  3. The instanceOf method is generic and accepts a FooObject constructor, and compares the typeId of the current object to the typeId of the passed-in constructor. The fact that it's generic is helping us keep the parent class from needing to know about each subclass, and avoiding the web of craziness you're running into above.

Okay, so let's create those concrete subclasses. We must remember to set the static typeId to a unique value:

class ThisObject extends FooObject {
  static readonly typeId = "ThisObject" // don't forget this
  thisMethod() {
    console.log("This!")
  }      
}

class ThatObject extends FooObject {
  static readonly typeId = "ThatObject" // don't forget this
  thatMethod() {
    console.log("That!")
  }
}

Okay, time to test it!

declare const someFooObject: FooObject;

if (someFooObject.instanceOf(ThisObject)) {
  someFooObject.thisMethod();
} else if (someFooObject.instanceOf(ThatObject)) {
  someFooObject.thatMethod();
} else {
  // some other type of FooObject, I guess
  console.log("I wasn't expecting that");
}

All the code above produces no compilation warnings for me, and it works for me at runtime.

Hope that helps; good luck!

Okay I've found a solution - but it's not great.

See that dance I had to do in main.ts of forcing the correct import order by importing the child class before the parent:

main.ts

import "./ThisFooObject"; // <-- forces correct order.
import FooObject from "./FooObject";

I have to double down and do that in all the children, for all the other ones. Since I only have two, this isn't a huge issue, but this scales exponentially with more subclasses. EG:

ThisFooObject.ts

import "./ThatFooObject" <-- Forces correct order despite not being used
import "./FooObject"

ThatFooObject.ts

import "./ThisFooObject" <-- Forces correct order despite not being used
import "./FooObject"
Related