Class constructor overloading in Typescript

129 views Asked by At

I am working on a class PlaybackControl, I use it to create two HTMLElements:

  1. PlaybackControlButton: HTMLButtonElement
  2. PlaybackControlMenu: HTMLDivElement

Now, to initialise the class object, I need three arguments:

  1. videoPlayer: HTMLVideoElement
  2. playbackRates: PlaybackRates
  3. 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,

  1. videoPlayer default to document.querySelector('video') as HTMLVideoElement
  2. playbackRates defaults to a static attribute PlaybackControl.DEFAULT_PLAYBACK_RATES
  3. options defaults to { enableShortcuts: true, shortcuts: PlaybackControl.DEFAULT_SHORTCUTS }

Now, I want to create an overloaded constructor which should work in all cases:

  1. No arguments passed
  2. Any combination of arguments passed Any value not given should fallback to its default,

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:

  1. Since, verbal descriptions might be hard to dive in, here you can find the entire code to run.

  2. 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

2

There are 2 answers

0
jcalz On BEST ANSWER

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.

Playground link to code

8
Bergi On

it this fails when I try to:

new PlaybackControl({ enableShortcuts: false })

I would not attempt to overload the constructor to accept this. It would need to check whether the first argument is a dom element or an options object, and then shuffle the arguments into the respective variables. This leads to rather ugly code and complicated type signatures.

Rather, to skip passing the first two arguments, pass undefined for them, then pass your options object as the third argument:

new PlaybackControl(undefined, undefined, { enableShortcuts: false })

If this is a common usage, consider changing your constructor to only accept one single object parameter, so that you'd call

new PlaybackControl({ options: { enableShortcuts: false } })