Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ReScript support #32

Merged
merged 57 commits into from
Feb 27, 2024
Merged

ReScript support #32

merged 57 commits into from
Feb 27, 2024

Conversation

cannorin
Copy link
Member

@cannorin cannorin commented Nov 9, 2021

Related: #166

Adds ReScript as a target.

TODOs:

Notes:

Subtyping

The "tag" subtyping should look like the following:

@unboxed type intf<-'tags, 'a> = { value: 'a }

module Node = {
  type content = { kind: string }
  type t = intf<[#Node], content>

  let from = (x: intf<[> #Node], _>) : t => Obj.magic(x)
}

module FooNode = {
  type content = {
    kind: [#"foo"],
    mutable foo: string
  }
  type t = intf<[#FooNode|#Node], content>

  let from = (x: intf<[> #FooNode], _>) : t => Obj.magic(x)
}

module BarNode = {
  type content = {
    kind: [#"bar"],
    mutable bar: float
  }
  type t = intf<[#BarNode|#Node], content>

  let from = (x: intf<[> #BarNode], _>) : t => Obj.magic(x)
}

In this way, it will allow one-step casting as well as an easy access to its fields:

let fooNode : FooNode.t = { value: { kind: #"foo", foo: "hello" } }
fooNode.value.foo = "goodbye"
let node = Node.from(fooNode)
Js.log(node.value.kind)
  • the content type should only have value fields (no methods = no overloading)
  • methods should have the signature of (this: t, ...) => ... rather than (this: content, ...) => ...

Discriminated Unions

There is an open feature request about this: rescript-lang/rescript#5207

A discriminated union should look like this:

module AnonymousUnion1 = {
  type t = { kind: string }
  type cases = [ #"foo"(FooNode.t) | #"bar"(BarNode.t) ]

  let foo : FooNode.t => t = Obj.magic
  let bar : BarNode.t => t = Obj.magic
  let classify = (t: t) : cases => %raw(`{ NAME: t.kind, VAL: t }`)
}

This makes it easy to handle union types with almost zero overhead:

let foo = AnonymousUnion1.foo({ value: { kind: #"foo", foo: "hello" } })
Js.log(foo)
let foo2 = AnonymousUnion1.classify(foo)
switch foo2 {
  | #"foo"(x) => Js.log(x.value.foo)
  | #"bar"(x) => Js.log(x.value.bar)
}

Callable & Newable Objects

A callable/newable object should look like this:

module FooConstructor {
  type t
  let invoke = (it: t, arg) : Foo.t => %raw(`it(arg)`)
  let create = (it: t, arg1, arg2) : Foo.t => %raw(`new it(arg1, arg2)`)
}

Maybe we should generate .resi files too

ReScript bindings are written in .res files, in which recursive modules are hard to write. But we can just generate a flat .res file and then generate a corresponding .resi file to hide internal types.

// Example.res

module Types = {
    module rec A : { type t = ... } = A
    and B : { type t = ... } = B
}

open Types

module A = {
    include Types.A
    ...
}

module B = {
    include Types.B
    ...
}
// Example.resi

module rec A : {
    type t = ...
    ...
}

and B : {
    type t = ...
    ...
}

@smorimoto smorimoto added documentation Improvements or additions to documentation enhancement New feature or request labels Nov 9, 2021
@cannorin
Copy link
Member Author

I'll hold this PR a bit (about 1 month) because I will focus on separate the target-agnostic part of the current implementation as a library:

  • AST
  • Parser
  • Typer
  • JS utilities

This refactoring will make it easier to work on ReScript support.

@cannorin
Copy link
Member Author

cannorin commented Mar 9, 2022

ReScript difficulties

Hard/unable to workaround

Can be workaround, but cumbersome to use

  • Can't bind to functions with variadic callback function: bind<T, AX, R>(this: (this: T, ...args: AX[]) => R, thisArg: T, ...args: AX[]): (...args: AX[]) => R;

    • can be workaround:
      module Variadic = {
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      type t0<'variadic, 't>
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      type t1<'arg1, 'variadic, 't>
      @ocaml.doc(`\`'args\` must be a tuple type. \`'variadic\` is expected to be array or some other iterable type.`)
      type tn<'args, 'variadic, 't>
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make0 : ('variadic => 't) => t0<'variadic, 't> = f => %raw(`(function(...args) { return f(args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make1 : (('arg1, 'variadic) => 't) => t1<'arg1, 'variadic, 't> = f => %raw(`(function(arg1, ...args) { return f(arg1, args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make2 : (('arg1, 'arg2, 'variadic) => 't) => tn<('arg1, 'arg2), 'variadic, 't> = f => %raw(`(function(arg1, arg2, ...args) { return f(arg1, arg2, args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make3 : (('arg1, 'arg2, 'arg3, 'variadic) => 't) => tn<('arg1, 'arg2, 'arg3), 'variadic, 't> = f => %raw(`(function(arg1, arg2, arg3, ...args) { return f(arg1, arg2, arg3, args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make4 : (('arg1, 'arg2, 'arg3, 'arg4, 'variadic) => 't) => tn<('arg1, 'arg2, 'arg3, 'arg4), 'variadic, 't> = f => %raw(`(function(arg1, arg2, arg3, arg4, ...args) { return f(arg1, arg2, arg3, arg4, args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make5 : (('arg1, 'arg2, 'arg3, 'arg4, 'arg5, 'variadic) => 't) => tn<('arg1, 'arg2, 'arg3, 'arg4, 'arg5), 'variadic, 't> = f => %raw(`(function(arg1, arg2, arg3, arg4, arg5, ...args) { return f(arg1, arg2, arg3, arg4, arg5, args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make6 : (('arg1, 'arg2, 'arg3, 'arg4, 'arg5, 'arg6, 'variadic) => 't) => tn<('arg1, 'arg2, 'arg3, 'arg4, 'arg5, 'arg6), 'variadic, 't> = f => %raw(`(function(arg1, arg2, arg3, arg4, arg5, arg6, ...args) { return f(arg1, arg2, arg3, arg4, arg5, arg6, args); })`)
      @ocaml.doc(`\`'variadic\` is expected to be array or some other iterable type.`)
      let make7 : (('arg1, 'arg2, 'arg3, 'arg4, 'arg5, 'arg6, 'arg7, 'variadic) => 't) => tn<('arg1, 'arg2, 'arg3, 'arg4, 'arg5, 'arg6, 'arg7), 'variadic, 't> = f => %raw(`(function(arg1, arg2, arg3, arg4, arg5, arg6, arg7, ...args) { return f(arg1, arg2, arg3, arg4, arg5, arg6, arg7, args); })`)
      @ocaml.doc(`\`'args\` must be a tuple type. \`'variadic\` is expected to be array or some other iterable type.`)
      let makeN : (('args, 'variadic) => 't, int) => tn<'args, 'variadic, 't> = (f, n) => %raw(`(function(...args) { return f(args.slice(0, n), args.slice(n)); })`)
      let apply0 = (f0: t0<'variadic, 't>, variadic: 'variadic) : 't => %raw(`f0(...variadic)`)
      let apply1 = (f1: t1<'arg1, 'variadic, 't>, arg1: 'arg1, variadic: 'variadic) : 't => %raw(`f1(arg1, ...variadic)`)
      let applyN = (fn: tn<'args, 'variadic, 't>, args: 'args, variadic: 'variadic) : 't => %raw(`fn(...args, ...variadic)`)
      }
  • Can't bind to "newable" functions, which must be called with new: new (arg: string): Foo

    • can be workaround:
      module Newable = {
      type t0<'t>
      type t1<'arg1, 't>
      @ocaml.doc(`\`'args\` must be a tuple type.`)
      type tn<'args, 't>
      let apply0 = (f0: t0<'t>) : 't => %raw(`new f0()`)
      let apply1 = (f1: t1<'arg1, 't>, arg1: 'arg1) : 't => %raw(`new f1(arg1)`)
      let applyN = (fn: tn<'args, 't>, args: 'args) : 't => %raw(`new fn(...args)`)
      }
  • Can't bind to global mutable values / static setters (e.g. location.href)

  • Can't bind to global values with exotic name (e.g. $&), while members with exotic name are allowed

  • Can't bind to enums with negative value: enum Foo = { A = -1, B = 0, ... }

Easy to workaround

@cannorin
Copy link
Member Author

Many things need to be polished (e.g. use @unwrap instead of Union.tn on args) and other packages are still failing to compile, but we have a first compilable ReScript binding to the typescript package, generated with ts2ocaml.

https://gist.github.com/cannorin/4cfd43318e4efe811d7d595a8f2c710b

@zth
Copy link

zth commented Mar 20, 2022

So cool!! 😄 👍

@cannorin
Copy link
Member Author

On hold until rescript-lang/rescript#5364 and rescript-lang/rescript#5368 are merged/addressed.

@cannorin cannorin force-pushed the rescript-support branch from 7fe1f67 to 97a6549 Compare May 9, 2022 07:14
@cannorin cannorin added the v2 label May 9, 2022
@cannorin cannorin changed the base branch from main to v2 May 9, 2022 07:22
@cannorin cannorin changed the base branch from v2 to main May 9, 2022 10:44
@cannorin cannorin force-pushed the rescript-support branch from 14e59ec to e9d3937 Compare May 26, 2022 06:40
@cannorin cannorin changed the base branch from main to v2 May 26, 2022 07:27
@smorimoto
Copy link
Member

I believe that new and variadic have finally landed with the latest ReScript version!
https://www.npmjs.com/package/rescript/v/10.0.0

@cannorin cannorin force-pushed the rescript-support branch 6 times, most recently from 227aed1 to 6796a30 Compare August 29, 2022 13:36
@cannorin
Copy link
Member Author

... it's compiling with rescript v10!

@d4h0
Copy link

d4h0 commented Nov 7, 2022

@cannorin: Thanks, for your work on this! I'm pretty excited about the possibility of not having to write every single binding by hand anymore! :)

I'm wondering: Is this already in a more or less usable state?

Currently, I'm trying to use Playwright by creating bindings manually, but there are like a million types (even if I only create bindings for what I use), so something like ts2ocaml would be fabulous... :)

Do you think, ts2ocaml could handle something as complex/huge as Playwright?

@cannorin
Copy link
Member Author

cannorin commented Nov 7, 2022

@d4h0 It should be possible to parse the type definitions of Playwright and generate a binding that compiles, but I don't think the generated binding would be user-friendly; it's still lacking some important optimizations that affect QoL (e.g. emit [#1 | #2 | #3] instead of Union.t3<[#1],[#2],[#3]>).

I'll fix the broken CI tomorrow or next just in case you want to try it out.

@zth
Copy link

zth commented Jul 18, 2023

@cannorin beta.4 is out with variant spreads and variant coercion, in case that helps.
https://github.com/rescript-lang/rescript-compiler/releases/tag/v11.0.0-beta.4

@cannorin
Copy link
Member Author

@zth Thanks! Variant-to-variant coercion will definitely help.

@mxj4
Copy link

mxj4 commented Aug 15, 2023

Thanks for creating this, the generated binding looks great! What is the strategy for TS definitions with lots of 3rd party TS definition dependencies? To build ReScript typing for a React component from TS definition, I had to build my own script on top of npm package managers to fetch all dependencies and manipulate the output file names.

Then I found, since the TS definition depends on @types/react, now I have to generate ReScript binding from @types/react, but ReScript community already has its own binding for React, and you already have a react application using the hand-crafted react binding @rescript/react, now the generated binding is incompatible with the hand-crafted binding.

@cannorin
Copy link
Member Author

@mxj4 Thank you for trying it out! In general, users may need to modify the generated bindings to make it work with the existing hand-crafted bindings. However, I do think React component packages deserve some special treatment.

@cannorin
Copy link
Member Author

cannorin commented Sep 3, 2023

@zth In ts2ocaml, classes and interfaces are represented as abstract types to allow subtyping, field shadowing, and method overloading at the same time (which is impossible if they are defined as record types).

Would it be possible to "inline" the arguments of zero-cost-binding variant types to accomplish something like below?:

type intf<-'tags>

// interface ShapeLike { kind: string }
module ShapeLike = {
  type t = intf<[#Shape]>
  type tags = [#Shape]
  type this<'tags> = intf<'tags> constraint 'tags = [> #Shape]
  
  @get external kind: this<'tags> => string = "kind"
}

// interface Circle extends ShapeLike { kind: "circle", radius: number }
module Circle = {
  type t = intf<[#Circle | ShapeLike.tags]>
  type tags = [#Circle | ShapeLike.tags]
  type this<'tags> = intf<'tags> constraint 'tags = [> #Circle]

  @get external kind: this<'tags> => [#circle] = "kind"
  @get external radius: this<'tags> => float = "radius"
  @obj external make: (~kind: [#circle], ~radius: float) => t = ""
}

// interface Square extends ShapeLike { kind: "square", sideLength: number }
module Square = {
  type t = intf<[#Square | ShapeLike.tags]>
  type tags = [#Square | ShapeLike.tags]
  type this<'tags> = intf<'tags> constraint 'tags = [> #Square]

  @get external kind: this<'tags> => [#square] = "kind"
  @get external sideLength: this<'tags> => float = "sideLength"
  @obj external make: (~kind: [#square], ~sideLength: float) => t = ""
}

let circle: Circle.t = Circle.make(~kind=#circle, ~radius=4.2)
let circleKind: string = circle->ShapeLike.kind // this works because of subtyping via `intf<-'tags>`

// type Shape = Circle | Square
module Shape = {
  @tag("kind")
  type t =
    | @as("circle") @unsafe_inline Circle(Circle.t)
    | @as("square") @unsafe_inline Square(Square.t)
}

let circleShape = Shape.Circle(circle)
// I want this to be compiled to:
//
//   var circleShape = circle

let getAreaOfShape = (shape: Shape.t) =>
  switch (shape) {
    | Shape.Circle(c) => c->Circle.radius *. c->Circle.radius *. Js.Math._PI
    | Shape.Square(s) => s->Square.sideLength *. s->Square.sideLength
  }
// I want this to be compiled to:
//
// function getAreaOfShape(shape) {
//   if (shape.kind === "circle") {
//     return shape.radius * shape.radius * Math.PI;
//   }
//   return shape.sideLength * shape.sideLength;
// }

@listepo
Copy link

listepo commented Sep 5, 2023

Hi @cannorin in your opinion, how long do we have to wait before we can use it?

@cannorin
Copy link
Member Author

cannorin commented Sep 5, 2023

@listepo You could use it today if you can build it yourself. As for the official release, I guess it will be around the v11 release of ReScript, as ts2ocaml depends on the new features.

@baku-yaki
Copy link

Rescript 11 is released. Is this package ready now?

@cannorin
Copy link
Member Author

cannorin commented Jan 30, 2024

@baku-yaki Thank you for letting me know, I'll make some changes for releasing (since it is currently using a quite old RC version of v11). I think it will be live within a month!

@cannorin
Copy link
Member Author

cannorin commented Jan 30, 2024

TODO List:

  • Remove the last unit argument from functions with optional arguments (with the arrival of uncurried mode)
  • Implement doc comment generators (we put it off since ReScript didn't have it when this PR was created).
  • Revisit tagged union. We can't utilize ReScript v11's tagged union, so we must emit a custom classifier function for each tagged union types (described in ReScript support #32 (comment))
  • Setup automated publishing of ts2ocaml-rescript-stdlib? (might not be needed since the stdlib is not that big and users can just do ts2ocaml res --create-stdlib?)
  • Write a tutorial!

Future Works:

  • Add a direct support for React components (requested in ReScript support #32 (comment))
  • Record mode (unsafe): ts2ocaml currently uses abstract types (intf<'tags>) to represent TS classes and interfaces, but an idiomatic ReScript binding would use record types. In general, record types are insufficient for binding to TS classes (since records cannot have overloaded methods). But library authors would want to have them generated as record types anyway to avoid boilerplate works.
  • Enable emitting the doc comment for recursive modules once ReScript v11.1 is released.

@cannorin cannorin marked this pull request as ready for review February 27, 2024 07:21
@cannorin cannorin merged commit a4a48b3 into main Feb 27, 2024
6 checks passed
@cannorin cannorin deleted the rescript-support branch February 27, 2024 07:46
@cannorin
Copy link
Member Author

I have just released the ReScript support as 2.0.0-beta.0!

Although I have tested that the generated binding compiles for a variety of NPM packages (namely, react, yargs, and typescript's SDK), but there may still be some edge cases that break the generated code. There may also be some cases that the generated binding compiles but does not work (e.g. broken import/require). Some may be hard to fix, but I want to fix the easier and/or critical ones before the release of v2.0.0.

Please try it out by npm install -g @ocsigen/ts2ocaml@beta!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants