TS reuse this.constructor in parent method

117 Views Asked by At

I have a base Message class and an subclass SpecializedMessaged for extra features

class Message {
    constructor(
        readonly content: string
    ) { }
    edit(replacement: string) {
        return new Message(replacement)
    }
}

class SpecializedMessage extends Message {
    extra() {}
}

Since the .edit() logic will be the same for Message and all sub-classes, I implement it once in base Message to be inherited. Immutability is important in my use case, so a new instance must be returned every edit.

However,

        return new Message(replacement)

will result in an undesirable cast of child instances back to the base type

let message = new SpecializedMessage('hello')
message.extra() // works
message = message.edit('world') // message now instanceof `Message` instead of `SpecializedMessage`
message.extra() // fails, `Message` lacks `.extra()`

I attempt to solve this using this.constructor to automatically use the constructor of whatever class instance is being used

class Message {
    // ...
    edit(replacement: string) {
        return new this.constructor(replacement)
    }
}

but TS alerts

This expression is not constructable.
  Type 'Function' has no construct signatures.ts(2351)
(property) Object.constructor: Function

despite the code working in vanilla JavaScript.

I am able to quiet the errors by casting this.constructor as a constructor

class Message {
    // ...
    edit(replacement: string) {
        const ctor = this.constructor as { new (content: string): Message}
        return new ctor(replacement)
    }
}

but want a solution that doesn't require an extra step.

Why is TS rejecting my usage of this.constructor()? How can I solve it?

I understand that the approach is inherently unsafe so long as subclasses can have unique constructor signatures, but that doesn't seem to be what TS is concerned with from the error message.

1

There are 1 best solutions below

7
Peter Seliger On


Edit / Note ... due to some of @Bergi's comments and an additional one from @TmTron

Bergi's and TmTron's proposed type assertions of this.constructor as typeof Message and as typeof this as return type of cause are the golden path one should walk.

The OP's implementation would change slightly to ...

class Message {
  constructor(
    readonly content: string
  ) {
  }
  edit(replacement: string) {
    return new (this.constructor as typeof Message)(replacement) as typeof this;
  }
}

class SpecializedMessage extends Message {
  extra() {}
}


... original answer ...

An implementation like ...

class Message {
  constructor(
    readonly content: string,
    public messageType = new.target,
  ) {
  }
  edit(replacement: string) {
    return new this.messageType(replacement)
  }
}

class SpecializedMessage extends Message {
  extra() {}
}

... which makes use of new.target does not raise any compiler warnings.

The above code provided in its vanilla flavor works as intended by the OP and looks like this ...

class Message {
  constructor(content, messageType = new.target) {
    this.content = content;
    this.messageType = messageType;
  }
  edit(replacement) {
    return new this.messageType(replacement);
  }
}
class SpecializedMessage extends Message {
  extra() {}
}
const defaultMsg = new Message("default message");
const specialMsg = new SpecializedMessage("special message");

console.log(
  specialMsg.edit(`yet another ${ specialMsg.content }`).content
);
console.log(
  specialMsg.edit(`yet another ${ specialMsg.content }`) instanceof Message
);
console.log(
  specialMsg.edit(`yet another ${ specialMsg.content }`) instanceof SpecializedMessage
);

console.log(
  defaultMsg.edit(`yet another ${ defaultMsg.content }`).content
);
console.log(
  defaultMsg.edit(`yet another ${ defaultMsg.content }`) instanceof Message
);
console.log(
  defaultMsg.edit(`yet another ${ defaultMsg.content }`) instanceof SpecializedMessage
);
.as-console-wrapper { min-height: 100%!important; top: 0; }