I am working on a class PlaybackControl
, I use it to create two HTMLElements:
PlaybackControlButton: HTMLButtonElement
PlaybackControlMenu: HTMLDivElement
Now, to initialise the class object, I need three arguments:
videoPlayer: HTMLVideoElement
playbackRates: PlaybackRates
options: PlaybackControlOptions
where:
type Shortcuts = Record<string, { key: string, value: string }>
type PlaybackRates = string[]
interface ShortcutsEnabled {
enableShortcuts: true
shortcuts: Shortcuts
}
interface ShortcutsDisabled {
enableShortcuts: false
}
interface DefaultOptions extends ShortcutsEnabled {}
type PlaybackControlOptions = DefaultOptions | ShortcutsDisabled
Also, I have default values for all of them,
videoPlayer
default to document.querySelector('video') as HTMLVideoElement
playbackRates
defaults to a static attribute PlaybackControl.DEFAULT_PLAYBACK_RATES
options
defaults to { enableShortcuts: true, shortcuts: PlaybackControl.DEFAULT_SHORTCUTS }
Now, I want to create an overloaded constructor which should work in all cases:
Lastly, videoPlayer: HTMLVideoElement
is the only argument which I want to store as a class attribute, rest two are just arguments which I just want to some function calls in the constructor (because I have no later use for them).
Currently, the constructor that I wrote is:
constructor (videoPlayer?: HTMLVideoElement, playbackRates: PlaybackRates = PlaybackControl.DEFAULT_PLAYBACK_RATES, options: PlaybackControlOptions = { enableShortcuts: true, shortcuts: PlaybackControl.DEFAULT_SHORTCUTS })
while this does allow me to initialise without any argument but this fails when I try to:
new PlaybackControl({ enableShortcuts: false })
and VSCode shows error that:
Object literal may only specify known properties, and 'enableShortcuts' does not exist in type 'HTMLVideoElement'.
while I do understand the underlying problem (I guess), I am unable to resolve this. Any help is appreciated.
Edit:
Since, verbal descriptions might be hard to dive in, here you can find the entire code to run.
By any combination of arguments
clarification:
I should be able to give whatever argument I want to set manually (in order with some missing) and the rest fallback to default
The main problem here is that your implementation of the constructor will be crazy, as it searches through the inputs for the properties of the right types. It essentially requires that no two parameters are of the same type, since otherwise there's an ambiguity... what if you had a: string, b: string
? And the caller writes new X("c")
? Which argument should be set to "c"
? For your code as written the implementation might be something like
class PlaybackControl {
static DEFAULT_PLAYBACK_RATES = [];
static DEFAULT_SHORTCUTS = {};
constructor(
...args: ???) {
const a: (HTMLVideoElement | PlaybackRates | PlaybackControlOptions)[] = args;
const videoPlayer = a.find((x): x is HTMLVideoElement =>
x instanceof HTMLVideoElement);
const playbrackRates = a.find((x): x is PlaybackRates =>
Array.isArray(x) && x.every(e => typeof e === "string")
) ?? PlaybackControl.DEFAULT_PLAYBACK_RATES;
const options: PlaybackControlOptions = a.find((x): x is PlaybackControlOptions =>
x && typeof x === "object" && "enableShortcuts" in x
) ?? { enableShortcuts: true, shortcuts: PlaybackControl.DEFAULT_SHORTCUTS };
}
}
That should work and behave how you like, but it's so fragile.
Anyway, assuming you do want such an implementation, there remains the question of how to type it. You say the input must be videoPlayer: HTMLVideoElement, playbackRates: PlaybackRates, options: PlaybackControlOptions
in order with some possibly missing, so, for example, you're not going to pass in options
before videoPlayer
if they both exist.
You could just manually overload the constructor to accept every possible input combination. But I like playing with types, so let's write a Subsequence<T>
utility type that takes an input tuple type T
and produces a union of every possible subsequence of T
.
Then the constructor input could be:
type Subsequence<T extends any[]> =
T extends [infer F, ...infer R] ? [F, ...Subsequence<R>] | Subsequence<R> : []
That gives
type X = Subsequence<[videoPlayer: HTMLVideoElement, playbackRates: PlaybackRates, options: PlaybackControlOptions]>;
/* type X = [] | [PlaybackControlOptions] | [PlaybackRates] | [PlaybackRates, PlaybackControlOptions] |
[HTMLVideoElement] | [HTMLVideoElement, PlaybackControlOptions] | [HTMLVideoElement, PlaybackRates] |
[HTMLVideoElement, PlaybackRates, PlaybackControlOptions] */
And thus the full constructor looks like
class PlaybackControl {
static DEFAULT_PLAYBACK_RATES = [];
static DEFAULT_SHORTCUTS = {};
constructor(
...args: Subsequence<[videoPlayer: HTMLVideoElement, playbackRates: PlaybackRates, options: PlaybackControlOptions]>
) {
const a: (HTMLVideoElement | PlaybackRates | PlaybackControlOptions)[] = args;
const videoPlayer = a.find((x): x is HTMLVideoElement =>
x instanceof HTMLVideoElement);
const playbrackRates = a.find((x): x is PlaybackRates =>
Array.isArray(x) && x.every(e => typeof e === "string")
) ?? PlaybackControl.DEFAULT_PLAYBACK_RATES;
const options: PlaybackControlOptions = a.find((x): x is PlaybackControlOptions =>
x && typeof x === "object" && "enableShortcuts" in x
) ?? { enableShortcuts: true, shortcuts: PlaybackControl.DEFAULT_SHORTCUTS };
}
}
And it behaves as desired.
new PlaybackControl({ enableShortcuts: false })
But, is it worth it? Almost certainly not. Crazy implementation plus crazy typings equals too much crazy. The conventional way to do something like this is to take a single object input of type {videoPlayer?: HTMLVideoElement, playbackRates?: PlaybackRates, options?: PlaybackControlOptions}
to take advantage of the natural indifference to property order that objects in JavaScript give you. The ambiguity goes away (e.g., {a?: string, b?: string}
would require either {a: "c"}
or {b: "c"}
, and the typing is very straightforward. So you should do that instead.