rescript-lang / experimental-rescript-webapi

Experimental successor to rescript-webapi
https://rescript-lang.github.io/experimental-rescript-webapi/
MIT License
12 stars 1 forks source link

Generating methods #3

Open nojaf opened 2 weeks ago

nojaf commented 2 weeks ago

As mentioned in https://github.com/rescript-lang/experimental-rescript-webapi/issues/2#issuecomment-2453025647, I'm looking into generating methods via the typescript tool.

The first example I'm trying to implement is already an interesting case: https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/connect

Naively it can be added as:

type rec audioNode = {
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/numberOfInputs)
    */
  numberOfInputs: any,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/numberOfOutputs)
    */
  numberOfOutputs: any,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/channelCount)
    */
  mutable channelCount: any,

  // this does work though
  connect: (~destinationNode: audioNode, ~output: int=?, ~input: int=?) => unit,
}

connect has overloads. In this case, I'm lucky and the overloads are all optional parameters. But this might not always be the case.

In case of https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener the last parameter is different and thus the optional types don't work there.

type any

type listener

type eventTarget = {
  addEventListener: (
    ~type_: string,
    ~listener: listener,
    ~options: any=?,
    ~useCapture: bool=?,
  ) => unit,
}

let et: eventTarget = %todo
let callback: listener = %todo

et.addEventListener(~type_="click", ~listener=callback, ~useCapture = true)

generates:

et.addEventListener("click", callback, undefined, true);

so optional arguments won't always work.

It is also not possible to define overloads inside the record using the @as annotation:

type rec audioNode = {
  connect: (~destinationNode: audioNode, ~output: int=?, ~input: int=?) => unit,
  @as("connect") connectWithOutput: (audioNode, int) => unit,
}

// The field connect is defined several times in the record audioNode. Fields can only be added once to a record.

Using a nested module and @send seems like a good trade-off in this case:

/**
A generic interface for representing an audio processing module. Examples include:
[See AudioNode on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode)
*/
type rec audioNode = {
   ...eventTarget,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/numberOfInputs)
    */
  numberOfInputs: any,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/numberOfOutputs)
    */
  numberOfOutputs: any,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/channelCount)
    */
  mutable channelCount: any,
}

module AudioNode = {
  @send external connect: (audioNode, audioNode) => unit = "connect"

  @send
  external connectWithOutput: (audioNode, audioNode, any) => unit = "connect"

  @send
  external connectWithOutputAndInput: (audioNode, audioNode, any, any) => unit =
    "connect"
}

let a: audioNode = %todo
let b: audioNode = %todo
let c: any = %todo

a->AudioNode.connectWithOutput(b, c)

Another thought I have about having dedicated modules for these functions is that we can duplicate the inherited functions and thus avoiding the need to cast it first to a base interface.

type any

type listener

type eventTarget = {}

module EventTarget = {
  @send
  external addEventListener: (eventTarget, string, listener) => unit =
    "addEventListener"
}

/**
A generic interface for representing an audio processing module. Examples include:
[See AudioNode on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode)
*/
type rec audioNode = {
  ...eventTarget,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/numberOfInputs)
    */
  numberOfInputs: any,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/numberOfOutputs)
    */
  numberOfOutputs: any,
  /**
    [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioNode/channelCount)
    */
  mutable channelCount: any,
}

module AudioNode = {
  // there will always be scenarios where you want to convert
  external asEventTarget: audioNode => eventTarget = "%identity"

  @send
  external addEventListener: (audioNode, string, listener) => unit =
    "addEventListener"
}

let a: audioNode = %todo
let callback: listener = %todo

a->AudioNode.addEventListener("click", callback)

This again helps with the discoverability if we can nail the trick of showing the completion of addEventListener (from the AudioNode module) after pressing dot in the editor.

Curious to hear your feedback!

zth commented 1 week ago

It is also not possible to define overloads inside the record using the @as annotation:

This is interesting and something that has come up before. When records are used as a binding primitive for handling external data, it would kind of make sense to allow multiple fields with different types pointing to the same underlying field, and just let the developer be "on their own" in terms of if that causes problems at runtime. Just like how bindings work today in general, ie you're responsible of binding the correct thing, and with a few notable exceptions like untagged variants, the runtime type won't be checked.

I wonder what consequence it'd have to allow that in some form though. Coercion becomes a problem obviously, so maybe coercion shouldn't be allowed at all if the record were to have multiple @as for the same field. Curious to hear more thoughts.

Allowing that in some form would solve a lot of problems though... It'd allow us to use records for a larger amount of bindings, and not have to resort to @send external as often.