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:
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."
);
}
}
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.