Type for only subclasses of a class

Issue

If I have a base class and multiple sub classes that inherit from this base, e.g:

class Message {
      constructor(public text: string) {}
}

class MessageA extends Message {
    msgType: string = 'A';

    constructor(
        public text: string,
        public aData: string
    ) {
        super(text);
    }
}

class MessageB extends Message {
    msgType: string = 'B';

    constructor(
        public text: string,
        public bData: number
    ) {
        super(text);
    }
}

… is it possible to create a type that only admits the sub classes?

I want something like

type Msg = MessageA | MessageB;

… that would allow me to write

const show = (msg: Msg): void => {
    switch (msg.msgType) {
        case 'A':
            return console.log(`${msg.text}: ${msg.aData}`);
        case 'B':
            return console.log(`${msg.text}: ${msg.bData}`);
    }
}

However, the code above gives me this error:

Property 'aData' does not exist on type 'Msg'.
  Property 'aData' does not exist on type 'MessageB'.

If I change the type to be the following, show works again:

type Msg = ({msgType: 'A'} & MessageA)
         | ({msgType: 'B'} & MessageB);

But if I then call show with a new MessageB

show(new MessageB('hello', 300));

… I get this error:

Argument of type 'MessageB' is not assignable to parameter of type 'Msg'.
  Type 'MessageB' is not assignable to type '{ msgType: "B"; } & MessageB'.
    Type 'MessageB' is not assignable to type '{ msgType: "B"; }'.
      Types of property 'msgType' are incompatible.
        Type 'string' is not assignable to type '"B"'.(2345)

What is a good way to create a type for the subclasses of a base class?

The goal here is to ensure that a handler will inspect the type of the object it was passed before it can access any fields (that aren’t in the base class) and also ensure that the switch is exhaustive for when future sub classes are added.

Solution

It looks like you want Msg to be a discriminated union, with msgType as a discriminant property.

Unfortunately you have annotated the msgType properties on MessageA and MessageB to be string, and string is not a valid discriminant property type. Discriminants must be unit types that only accept a single value, like undefined or null, or a string/numeric/boolean literal type.

Instead of string, you want them to be of the string literal types "A" and "B", respectively. You can do this either by explicitly annotating them as such, or by marking them as readonly, which will cause the compiler to infer literal types for them:

class MessageA extends Message {
  readonly msgType = 'A';
  // (property) MessageA.msgType: "A"

  constructor(
    public text: string,
    public aData: string
  ) {
    super(text);
  }
}

class MessageB extends Message {
  readonly msgType = 'B';
  // (property) MessageB.msgType: "B"

  constructor(
    public text: string,
    public bData: number
  ) {
    super(text);
  }
}

Once you do this, the narrowing in your show() function will just work:

const show = (msg: Msg): void => {
  switch (msg.msgType) {
    case 'A':
      return console.log(`${msg.text}: ${msg.aData}`); // okay
    case 'B':
      return console.log(`${msg.text}: ${msg.bData}`); // okay
  } 
}

Playground link to code

Answered By – jcalz

Answer Checked By – Timothy Miller (AngularFixing Admin)

Leave a Reply

Your email address will not be published.