glutinum-org / cli

https://glutinum.net/
59 stars 5 forks source link

Transform options interface to class with `[<ParamObject>]` #32

Open MangelMaxime opened 10 months ago

MangelMaxime commented 10 months ago

TypeScript documentation:

interface IntersectionOptions {
    trackVisibility?: boolean;
    delay?: number;
}

translates into


[<Global>]
type IntersectionOptions
    [<ParamObject; Emit("$0")>]
   (?trackVisibility: bool, ?delay: float)
    =
    member val trackVisibility: bool option = jsNative with get, set
    member val delay: float option = jsNative with get, set

The question is how to detect such interfaces? The simpler way for a first implementation could be to transform any interface which ends with Options as this seems to be a pretty common pattern in TypeScript code.

A more complex ways would be to detect that this interface is consumed by a method / function, and in that case consider this is an options object / POJO and not an API mapping.

We also need to support constructor overloading in order to unwrap union type inside of the parameters:

interface IntersectionOptions {
    trackVisibility?: boolean | string;
    delay?: number;
}

translates into

[<Global>]
type IntersectionOptions private () =

    [<ParamObject; Emit("$0")>]
    new(?trackVisibility: bool, ?delay: float) = IntersectionOptions()

    [<ParamObject; Emit("$0")>]
    new(?trackVisibility: string, ?delay: float) = IntersectionOptions()

    member val trackVisibility: U2<bool, string> option = jsNative with get, set
    member val delay: float option = jsNative with get, set

In F# a secondary constructor needs to call the primary one so we create a dummy private primary constructor which is easy to please as it takes not arguments.

Example of npm package using this features:

react-intersection-observer

MangelMaxime commented 10 months ago

During the past few days, I had to work on several bindings:

They both have candidates for using [<ParamObject>] and both of them don't use the Options suffix. This confirms that we need to detect when an interface is called by an API.

Something to note, is that even that solution is not perfect because in the case of react-google-autocomplete the interface definition is not inside of the react-google-autocomplete but in another package @types/google.maps and so we will probably not be able to detect that case.

Only way to detect such a case would be to follow dts2hx philosophy which is to generate the bindings needed by the user inside of the project and not share bindings via NuGet.

CleanShot 2024-01-18 at 14 58 51@2x


Detecting if a type is used by another definition is not something unique to this issue. For example, Transform methods with union type as arguments to overloaded methods - #29 has a similar requirements.

For #29, a naive approach could be to iterate over the F# AST after it has been fully generated and for each union type check if it is used as paramater or return type. If not, we can erase it however it feels like we are going to iterate over the AST a lot of time by doing so.

So since a few days, what I have in mind is to keep track of a counter reference which would allows us to know if a type can be removed or not. That counter could also serve to detect if a type is used as a parameter of a function/method and if yes then would switch to generating [<ParamObject>] for that type.

My idea for the counter is to have a something like:

type UsageCounter =
    {
        mutable UsageAsArgument: int
        mutable UsageAsReturnType: int
    }

// References instance could be saved on the `TypeScriptReader` and then forwarded/propagated to the `Transformer`
let references =
    [
        // Candidate for [<ParamObject>] transformation
        "dayjs.DayJsOption", {
            UsageAsArgument = 1
            UsageAsReturnType = 2
        }

        // Standard generation using interface
        "dayjs.Calendar", {
            UsageAsArgument = 0
            UsageAsReturnType = 2
        }

        // Candidate for being erased of the output
        "dayjs.Period", {
            UsageAsArgument = 0
            UsageAsReturnType = 0
        }
    ]

We would built the counter reference during the reader phase and then during the transformation phase we would use/update the counters depending on what we are doing. Then once the transformation is done we could in a single pass on the F# AST erase all the types where both counter are equal to 0. If we want to avoid an iteration over the AST, we could let the printer decide to print or not the type as the print will always do a full pass on the AST.

However, this open situation where a type is never used by an internal API because it is just defined to be used by an external package. So perhaps, we should also have a flag which mark candidates for erasing like if the type is an union type and was transformed using method overloading for example.

There are probably others ways of doing that, my goal here is just to start a discussion for people following this repository. If you have questions or if you need me to be more clear in the situation we are trying to solve please feel free to ask :)