rescript-lang / rescript

ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript.
https://rescript-lang.org
Other
6.76k stars 449 forks source link

Can't genType a first class module to share with TypeScript #6023

Closed jmagaram closed 1 month ago

jmagaram commented 1 year ago

According to the docs at genType first class modules I should be able to use syntax like export ... but this doesn't work. So maybe the docs need to change.

I have been struggling with how to share my modules with TypeScript. Originally I was sprinkling @genType annotations all over the place but I couldn't figure it out. I tried decorating modules and members of modules. I tried putting the @genType annotation on the interfaces and on the implementations. Sometimes I used @genType.ignoreInterface. Just couldn't get reliable results. Perhaps part of the problem is I'm using functors. Ideally I could slap a @genType on the final module that was built with a bunch of functors and includes.

And so today I thought maybe I could have a single file called TypeScriptInterop.res and I'd put a bunch of first class modules in there, slap @genType on them, and it would all work. And since I have to manually specify the type of these modules, it would seem like genType has all the information it needs regardless of whether they were built with functors and include statements. But no. Here is a single file. I'm trying to make CompanyName available to TypeScript.

module type T = {
  type t
  type domain
  let make: domain => result<t, string>
  let value: t => domain
}

module Make = (
  C: {
    type domain
    let cmp: (domain, domain) => float
    let validate: domain => result<domain, string>
  },
): (T with type domain = C.domain) => {
  type t = C.domain
  type domain = C.domain
  let value = i => i
  let make = (i: domain) => i->C.validate
}

module CompanyName = Make({
  type domain = string
  let cmp = Js.String2.localeCompare
  let validate = i => {
    let len = Js.String2.length(i)
    len > 1 && len < 20 ? Ok(i) : Error("Wrong length")
  }
})

// Can not use domain := string in place; must define separate module type. Why?
module type CompanyNameType = T with type domain := string

@genType
let companyName: module(CompanyNameType) = module(CompanyName)

And here is the .gen.tsx...

import * as ValidatedBS__Es6Import from './Validated.bs';
const ValidatedBS: any = ValidatedBS__Es6Import;

export const companyName: unknown = ValidatedBS.companyName;
jmagaram commented 1 year ago

Ok I think I figured it out. I'm defining a big data model and I want to consume it from TypeScript. The thing I didn't totally understand is that @genType is opt-in, not opt-out, and you can't put the annotation on a first-class module variable or on a module name and expect it to work. I probably don't need first class modules.

First, I need to put @genType on nearly EVERY item of every module. If I consistently use module types and put the annotations there it works. There are a few gotchas...

It would be nice (not high priority) if there was an opt-out version where you could put a @genType over a module and it would publish everything that is available like on the ReScript side. If it is available/public to ReScript it is available to TypeScript. I see why having both opt-in and opt-out could be confusing, especially with things that tweak how it works like @genType.as.

cristianoc commented 1 year ago

@jmagaram see fix to the docs in https://github.com/rescript-association/rescript-lang.org/pull/655

Feel free to provide PRs to improve other parts of the docs based on your findings.

jmagaram commented 1 year ago

No still can't reliably get this to work. I really don't know where to begin explaining what is going on. I don't understand the rules and they aren't documented anywhere.

In the example below, companyName1 becomes unknown but companyName2 is ok. I would assume they should generate identical or similar results. Why are there different results when a module type is inlined?

For some reason I can't use the type elimination operator := when I put the type inline. Why not?

If I don't put @genType on either first class module, nothing gets generated. But if I put a @genType let x = 0 somewhere in the file it generates tons of stuff. Likewise if I put that @genType annotation on top of the CompanyName module it generates tons of stuff even though I don't think that is supported; you can't annotate a module.

module type T = {
  @genType type t
  type domain
  @genType let make: domain => result<t, string>
  @genType let value: t => domain
}

module Make = (
  C: {
    type domain
    let cmp: (domain, domain) => float
    let validate: domain => result<domain, string>
  },
): (T with type domain = C.domain) => {
  type t = C.domain
  type domain = C.domain
  let value = i => i
  let make = (i: domain) => i->C.validate
}

module CompanyName = Make({
  type domain = string
  let cmp = Js.String2.localeCompare
  let validate = i => {
    let len = Js.String2.length(i)
    len > 1 && len < 20 ? Ok(i) : Error("Wrong length")
  }
})

// Can not use domain := string in place; must define separate module type. Why?
module type CompanyNameType = T with type domain := string

@genType
let companyName1: module(CompanyNameType) = module(CompanyName)

@genType
let companyName2: module(T with type domain = string) = module(CompanyName)
jmagaram commented 1 year ago

Here is a different situation where I assigned @genType properly in my modules but it isn't working. The gen.tsx code references a type t that isn't defined anywhere. I think in this situation it will not work if you use include in module types where the module is generated. But if you use the expanded (non-include) module type at the end - where you designate the first-class-module - it won't work; the damage has been done. Is it a known limitation to never use include in module types that have @genType annotations?

Maybe these two examples are the same. In the example above, I did module type CompanyNameType = ... and tried to use that in the first-class module definition and it didn't work. Maybe any time you assign a module type to another module type, or do some kind of transformation like include, all the @genType annotations are lost?

module type T = {
  @genType type t
  type domain
  @genType let make: domain => result<t, string>
  @genType let value: t => domain
}

module Make = (
  C: {
    type domain
    let validate: domain => result<domain, string>
  },
): (T with type domain = C.domain) => {
  type t = C.domain
  type domain = C.domain
  let value = i => i
  let make = i => i->C.validate
}

module type UniqueId = {
  // Works
  //   @genType type t
  //   @genType let make: string => result<t, string>
  //   @genType let value: t => string
  //   @genType let getRandom: unit => t

  // Does NOT work
  include T with type domain := string
  @genType let getRandom: unit => t
}

@module("nanoid")
external customAlphabet: (string, int) => (. unit) => string = "customAlphabet"

module MakeUniqueId = (
  C: {
    let minLength: int
  },
): UniqueId => {
  include Make({
    type domain = string
    let validate = i => Js.String2.length(i) < C.minLength ? Error("too short") : Ok(i)
  })
  let builder = customAlphabet("ABCDEFG012345", C.minLength)
  let getRandom = () => builder(.)->make->Result.getExn
}

module LongUniqueId: UniqueId = MakeUniqueId({
  let minLength = 20
})

@genType
let longUniqueId1: module(UniqueId) = module(LongUniqueId)
jmagaram commented 1 year ago

I'm not sure if I need first-class modules or not for exporting. Again I just ran an experiment where I'm creating a module module UserId = MakeString(... and no .gen.tsx is made. But if I add a @genType let genTypeTrigger = true it seems to generate everything I need like UserId_make etc.

cristianoc commented 1 year ago

What you're observing is basically a combination of

Because of this, this entire area is full of unexpected surprises. If you're interested, it would be useful to clean up some of this. At least document some of these gotchas, or perhaps put up big disclaimers in the docs. Some of the limitations one might get around by just changing some little detail that was overlooked as this is not a widely used combination. Others might be solvable, but at a huge complexity cost which is not justifiable (deeply change the type checker). Others won't be solvable, as the concepts just don't map to TS.

So I guess starting from a few best practices and documenting what's out there would come first. Followed by seeing what are more compelling use cases which are currently not supported and could be supported in future.

jmagaram commented 1 year ago

Some inconsistency about using include in module types. This first type does NOT work, although it is the most concise and what I really want. The .gen.tsx references a type t that isn't defined.

module type NanoId = {
   include Primitive.T with ...
   @genType let makeRandom: unit => t

...but this does work - all the CmpUtilities too - even though it also has an include

module type NanoId = {
   (most of the stuff from Primitive.T explicitly listed)
   include CmpUtilities.T...
   @genType let makeRandom: unit => t
jmagaram commented 1 year ago

I'd be happy to write some documentation. I'm not capable of doing any dev work on this. But I can't yet figure out what rules to follow.

I think of ReScript modules as a perfect analogue to ES6 modules; I don't see the disconnect with TypeScript. A ReScript module is just a collection of exported functions and types, just like an ES6 module. It seems to work well IF I don't use functors and include. I don't think my need here is about first class modules. It's about modules. If I've built a NanoId module in ReScript and I'm using it I can see its got 6 different functions and 1 opaque type. I just want to publish it so I can use it from TypeScript, as if I had marked everything inside with @genType. I don't care if the module was built with functors and include etc. I care about the end result type - what I see in the intellisense in VS Code.

It sounds like what you're saying is that by the time it gets to the intellisense in VS Code all the @genType annotations are lost. I tried applying @genType annotations after-the-fact, doing...

@genType let nanoId : module(type that I want with lots of genType annotations) = module(NanoId)

...but it didn't work. It was too late.

I'm surprised what I'm trying to do is so unusual. I'm trying to model the data domain of my app. Functors help with that. I've got a Validated functor to make specific types for the different kinds of data in my domain and they handle serialization, validation, and other type-specific needs. I want to consume this data model from TypeScript because I'm trying to do my UI in SolidJS. Even with React, there are also some parts of my app where I don't want to have to write bindings, and so it is easier to consume the data in TypeScript.

I might post a question on the forum to see what people are using genType for and if anyone has guidance for me. I'm really surprised more people aren't having the difficulties I am.

cristianoc commented 1 year ago

I'm not sure if I need first-class modules or not for exporting. Again I just ran an experiment where I'm creating a module module UserId = MakeString(... and no .gen.tsx is made. But if I add a @genType let genTypeTrigger = true it seems to generate everything I need like UserId_make etc.

This sounds like a simple inconsistency that should be fixed. Would you provided a minimal self-contained example?

cristianoc commented 1 year ago

I'm not sure if I need first-class modules or not for exporting. Again I just ran an experiment where I'm creating a module module UserId = MakeString(... and no .gen.tsx is made. But if I add a @genType let genTypeTrigger = true it seems to generate everything I need like UserId_make etc.

This sounds like a simple inconsistency that should be fixed. Would you provided a minimal self-contained example?

Might be fixed in master already: see the changelog here https://github.com/rescript-lang/rescript-compiler/pull/5903/files#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edR48

Adding it to the upcoming 10.1.3 too https://github.com/rescript-lang/rescript-compiler/pull/6026

jmagaram commented 1 year ago

Funny I was just going to ask you about this as a real bug. I just did a include NanoId.Make(... in a .res file and nothing got generated. Did the @genType let trigger = ... and everything I needed was built.

cristianoc commented 1 year ago

This is not likely to receive much attention, compared to other aspects of the compiler, given the lack of compelling use cases seen so far.

cristianoc commented 1 year ago

The general advice is to keep the code simple instead. As complex constructs do not come for free in terms of implementation cost.

jmagaram commented 1 year ago

Ok. Close bug if you want.

github-actions[bot] commented 2 months ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.