Home > Software engineering >  Require either one of two methods, or both in abstract class
Require either one of two methods, or both in abstract class

Time:01-13

For example, I have this code:

export abstract class AbstractButton {
    // Always have to provide this
    abstract someRequiredMethod(): void;

    // One need to provide one of these (or both)
    abstract setInnerText?(): void;
    abstract setInnerHTML?(): void;
}

I need a successor to implement setInnerText() or setInnerHTML(), or both.

How to achieve this using the powerfullest type system built by humans?

CodePudding user response:

You can use a type alias which uses a generic type parameter to discriminate which implementation should be required:

TS Playground

type AbstractButton<T extends 'both' |  'html' | 'text' = 'both'> =
  & { someRequiredMethod(): void }
  & (
    T extends 'html' ? { setInnerHTML(): void }
    : T extends 'text' ? { setInnerText(): void }
    :  {
      setInnerText(): void;
      setInnerHTML(): void;
    }
  );

Then you can use the implements clause to ensure conformance. Here are a few demonstrative examples from the TypeScript playground link above — check it out for a more exhaustive set:

class B5 implements AbstractButton<'html'> { /* Error
      ~~
Class 'B5' incorrectly implements interface 'AbstractButton<"html">'.
  Type 'B5' is not assignable to type '{ setInnerHTML(): void; }'.
    Property 'setInnerHTML' is missing in type 'B5' but required in type '{ setInnerHTML(): void; }'.(2420) */
  someRequiredMethod(): void {}
}

class B6 implements AbstractButton<'html'> { // ok
  someRequiredMethod(): void {}
  setInnerHTML(): void {}
}

class B7 implements AbstractButton<'text'> { /* Error
      ~~
Class 'B7' incorrectly implements interface 'AbstractButton<"text">'.
  Type 'B7' is not assignable to type '{ setInnerText(): void; }'.
    Property 'setInnerText' is missing in type 'B7' but required in type '{ setInnerText(): void; }'.(2420) */
  someRequiredMethod(): void {}
}

class B10 implements AbstractButton { /* Error
      ~~
Class 'B10' incorrectly implements interface 'AbstractButton<"both">'.
  Type 'B10' is not assignable to type '{ setInnerText(): void; setInnerHTML(): void; }'.
    Property 'setInnerText' is missing in type 'B10' but required in type '{ setInnerText(): void; setInnerHTML(): void; }'.(2420)
input.tsx(7, 7): 'setInnerText' is declared here. */
  someRequiredMethod(): void {}
  setInnerHTML(): void {}
}

class B12 implements AbstractButton { // ok
  someRequiredMethod(): void {}
  setInnerHTML(): void {}
  setInnerText(): void {}
}

CodePudding user response:

I've found a way to get a compile-time error, but it's ugly enough you may just want to require a mixin or similar, or just live with only a runtime test (throwing from the AbstractButton constructor), which will after all show up very early in the development of a subclass. (I show an example of the runtime check below, since you'll want it in any case even with the compile-time check for reasons described below.)

But I'll detail how I managed to get it to give me a compile-time error.

First, though, you can't define those optional abstract methods that way, they'll be required even though you have ? on them (playground example). So we'll need to define the base class without them and then find a way to ensure subclasses have at least one of them. So now our base class is just:

abstract class AbstractButton {
    // Always have to provide this
    abstract someRequiredMethod(): void;
}

I don't think you can do what you want to do purely with class ___ extends AbstractButton, but you can ensure a compile-time error with a do-nothing function that will cause a type error if the subclass doesn't implement at least one of those methods.

First, we need a helper mapped type to check to see if a constructor function's prototype has either of those methods:

type CheckRequired<Ctor> =
    [Ctor] extends [ { prototype: { setInnerText: () => void; } } ]
    ? Ctor
    : [Ctor] extends [ { prototype: { setInnerHTML: () => void; } } ]
    ? Ctor
    : {__error__: "AbtractButton subclasses must implement `setInnerText` and/or `setInnerHTML`"}};

We could make those checks more stringent (for instance, prevent a class from implementing a valid setInnerText but an invalid setInnerHTML [invalid because it takes different params than you want), but that's the general shape of it.

The fact we're testing tuples ([Ctor] extends ___, not Ctor extends ___) is important, more about that later.

Okay, now we need a function that does nothing but does typecheck what it gets:

function checkAbstractButtonSubclass<T>(cls: CheckRequired<T>) {}

Now, unfortunately, we have to require people to code a call to that in order to get the type error:

checkAbstractButtonSubclass(SomeButtonSubclass);

Here's a series of classes with the calls — note that the call for Neither causes a type error because Neither doesn't implement either setInnerText or setInnerHTML:

class Both extends AbstractButton {
    someRequiredMethod() { }
    setInnerText() { }
    setInnerHTML() { }
}
checkAbstractButtonSubclass(Both); // <== No error, it provides both

class JustText extends AbstractButton {
    someRequiredMethod() { }
    setInnerText() { }
}
checkAbstractButtonSubclass(JustText); // <== No error, it provides one of them

class JustHTML extends AbstractButton {
    someRequiredMethod() { }
    setInnerHTML() { }
}
checkAbstractButtonSubclass(JustHTML); // <== No error, it provides one of them

class Neither extends AbstractButton {
    someRequiredMethod() { }
}
checkAbstractButtonSubclass(Neither); // <== Error, it doesn't provide either:
// Argument of type 'typeof Neither' is not assignable to parameter of type '{ __error__: "AbtractButton subclasses must implement `setInnerText` and/or `setInnerHTML`"; }'

Having to code a pointless function call to get a compile-time type error is obviously less than ideal, but I don't think you can do it without. I did try to come up with a way to make the function call useful in some way, but unless you require a registration step for subclasses, I haven't figured one out yet. Maybe someone smarter than me will. :-)

Since people will forget to code that call, you'll want the AbstractButton constructor to do a runtime check as a backstop to the compile-time check (so it fails early in development if the coder doesn't write the call to checkAbstractButtonSubclass), something like:

constructor() {
    if (
        (!("setInnerText" in this) || typeof this.setInnerText !== "function") &&
        (!("setInnerHTML" in this) || typeof this.setInnerHTML !== "function")
    ) {
        throw new Error(
            "AbstractButton subclasses must implement either setInnerText or setInnerHTML. "  
            "You can use checkAbstractButtonSubclass(YourClass) to get a proactive compile-time "  
            "error for this instead of this runtime error."
        );
    }
}

Playground link

Finally: So why is the utility type checking [Ctor] against [{ prototype: { ___ } }] instead of just testing Ctor against { prototype: ____ }? Because we need to prevent the generic type argument from being distributed, because it breaks the tests. By wrapping it in a tuple, we disable the distribution.

But again, that's really ungainly and complicated, and a simple runtime check in the AbstractButton constructor without all the extra stuff will show up very early in the development of a subclass, so I think I'd probably just be happy with that.

  • Related