Several ways to do the same thing

Hello all!

I’m worried a little by the recent RFC discussions:

They got traction, and I think it’s a question of time when they would be introduced to the language. I understand I cannot stop the train (aka progress), and I don’t want to stop it. I just want to share my mind.

From my point of view, these two features make the language less orthogonal. This might lead to pointless community splits inside and across various projects written in ReScript. Should we use semicolons or not, how thingsShouldBeNamed, should we prefer data-first or data-last… These questions have a default of enforced answers. And that’s great!

The two active proposals will pose new questions. How should I design a thing constructor?

// This
module Delivery: {
  type t

  let make: (
    ~code: string,
    ~price: Money.t,
    ~tariffCode: string=?,
    ~city: string,
    ~cityCode: string=?,
    ~address: string,
    ~pickupPointCode: string=?,
    ~comment: string,
    unit,
  ) => t
}


// or this?


module Delivery: {
  type t

  @obj
  type opts = {
    code: string,
    price: Money.t,
    tariffCode: option<string>,
    city: string,
    cityCode: option<string>,
    address: string,
    pickupPointCode: option<string>,
    comment: string,
  }

  let make: (opts) => t
}

I suspect many will prefer the latter because it’s a pattern from the JS land. And it’s OK, it’s not a problem at all. The problem are the questions: “why the first way exists at all?”, “what the hell this trailing unit and () I see here and there?”. So, to become proficient in ReScript I’ll have to learn more. Of course, we cannot simply drop the labeled argument feature for the reason of backward compatibility. And we cannot refuse to introduce the structural typing of records because in other scenarios (interop) it helps a lot.

But can’t we stop for a moment and think how the two ways could co-exist and be mostly interchangeable? Those who know Python can remember the **kwargs idiom: it acts as a dictionary and an argument list simultaneously! Yeah, I know, technically, that’s another story, but I’m talking about the beauty of such dualism.

The same goes for “private by default”. I already have a powerful and working mechanism for this: the unfamous *.resi files. And I’m happy with it.

Although I see no problem with making *.resi for almost everything, I can understand the people who are enchanted by the pub / export concept. They either feel that *.resi acts as a boilerplate brake lowering productivity, or they are disappointed with IDE experience, which cannot guess what file it should navigate. I think I’m in the minority with my style of development (interface and docs first, then peer review, then implementation), and that’s OK. The problem is the questions: “should I use pub/export or module signatures?”, “if I may use both, why should I expose everything twice?”, “if export is enough, why *.resi ever exist?”.

I think the module signatures give the language a HUGE feature: the clear, uncluttered and dense API declarations. I suspect we cannot drop module signatures anyway because of the backward compatibility. And we cannot stop the progress from introducing the pub / export because they are so desirable by the community.

So, again, can we think about the elegant co-existence of two paradigms to make them interchangeable? It’s hard to provide a concrete example here, but is there a way to satisfy *.resi defenders and export defenders at the same time?

The *.resi defenders value the clear API definition (they don’t value having two files for every module, I guess). The export defenders value single-file encapsulation and fast DX. So, can we move all *.resi inside *.res and reuse the existing mechanisms to make both sides happy?

Thank you.

11 Likes

Hi, Thanks for rasing the concern.
Note the introduction of new attributes is to simplify the things.

For example, with the obj attribute, you don’t need to create a submodule or use @obj external attributes, and it is a first language language feature not tied to the FFI.

The private by default is a long requested feature request, and most langauges follow such rules. You can still use resi for library development so that the interface is a separate concern from the implementation. It is a bit too much to ask application devleopment authors to write resi for each file

This post throws in a lot of topics into one huge thing, and I am not sure how to give a meaningful comment in a structured manner, but I will try…

First of all, I do understand your concerns, because bigger changes to a language are indeed scary. I could probably sing you a song about it from the past few years.

I am not sure I fully understand that sentence… why is it a split in the community when some ppl prefer a make function, and some ppl prefer just using a record? In that manner it’s like saying First Class Modules are redundant, because I can just pass around functions instead? There’s always different teams coding in different patterns… the only thing we enforce are syntax style and compiler setup (at least keep it as low-config as possible).

Every programming language usually has multiple ways to do the same thing. Our docs are usually trying to be descriptive and (as you said correctly) tries to nudge the user to use certain patterns / language features. We also try to inform users with deprecation warnings wherever possible.

ReScript had some substantial changes over the past few iterations, and so far I think we did a pretty great job providing a lot of improvements without much inconsistencies / breakage. In the past we also removed quite some features without even anyone noticing (because we mitigated it through our syntax parser and printer).

Are you now criticising the existence of labeled arguments and curried functions, or are you criticising that we can do @obj? You probably forgot a third way, which is @obj external make: (~something:...) => t = "", which is what we are trying to get rid of in the first place to simplify our JSX transform.

Besides, there are also currently discussions on how to make ReScript an uncurried-by-default language to make quirks like this dangling () obsolsete. Our problem is that programming languages usually grow from small to big, while ReScript is actually shrinking from big to small (due to its compiler heritage). Not easy indeed.

I guess this is the core of your concerns, because you seem to care a lot about interface files. So let’s go into detail for this topic.

I am not enchanted by it, I just see all the upsides that I wasn’t able to see as soon as I had to worry about documentation generation and editor-support. Interface files > just for data hiding < make things way more complex than most of us would realize.

Here a few examples:

Interface files drop important information like parameter names, that would usually show up in the implementation file

Here’s what I mean:

// HelloWorld.res
let createUser(firstname: string, lastname: string): string {...}

// HelloWorld.resi
let createUser: (string, string) => string

How would you represent those strings without falling back to different semantics like labeled arguments? Comments? Really inefficient if you ask me.

Interface files require the user to understand syntax transformations

Did you ever try to write an interface file for a React component without the @react.component transform? Did you ever try to explain this to a complete newcomer? Interface files for React components are most of the time a key-typing practise, and a great waste of mental resources, and we had to implement very specific jump-to-definition behavior in our editor-tooling just to be able to jump efficiently between React component implementations.

This is what Bob said about application development: You often just want to be immersed in the implementation, not doing some sort of “interface first” approach. This is not how JS developers work. Maybe that’s how C or OCaml developers work, but this is certainly not our target group.

You can still do type-driven design in implementation files though… ReScript thoroughly enforces that kind of design due to its type-system strictness anyways.

Interface files have a different syntax ruleset

This one is probably underrated… it’s extremely irritating when you drop into a resi file and write:

let makeUser: (firstname: string, lastname: string) => string

and you get a cryptic error message at firstname. Even thought the syntax looks extremely similar, we still have different rules to comply to, and this is yet another thing we need to teach (and also maintain). I stopped teaching resi files because of that… it is a stop gap between us and new learners getting things done. As soon as you introduce them due to a certain limitation within a res file, you can be certain to get at least some frown faces and weird question on how to do X and Y.

Interface files are a copy paste nightmare

It’s extremely clunky to copy concrete type definitions back and forth between .res and .resi files. How many times was I thrown out of my zone for copying the very same type definition back and forth. If a file doesn’t compile, the editor-tooling bails out as well. No up-to-date autocompletion if you don’t fix that silly mismatch error.

For zero-cost bindings, writing interface files is actually quite weird. To achieve real zero-cost in the compilation output, you literally need to copy the whole content from .res to .resi.

I honestly don’t want to write custom tooling just to mitigate that issue.

Interface files require a lot of extra tooling

  • Our playground currently doesn’t allow interface file editing… and I don’t even know how we’d effectively represent that on the playground UI… no proper way sharing code on interface files, even though it’s an essential feature for data hiding.
  • Currently our resi files are not properly highlighted on Github due to their rules that there must be a certain number of resi files on a lot of repositories first until we can send a PR to Github linguist.
  • Interface files for data hiding means that I can’t just share a single .res files for e.g. bindings. If I’d create a gist, I’d need to create a two-file gist with both, .res and .resi… extremely annoying from a UX perspective.

Multiple ways of doing Documentation generation

So let’s say I have this React component, and want to document it. Where do I put it? Ideally in an interface file… but I don’t want to create an interface file just for this one doc string.

So now I have to think about two ways of parsing docs, which is twice the maintenance effort: Either parse the resi file AST, or alternatively parse the actual TypedTree for the implementation file. It’s pretty common to ommit annotations in implementation files as well, which IMO is already pretty hostile against anyone who wants to read an implementation file. I don’t want to constantly switch back and forth between res and resi files, and sometimes I just don’t have any tooling that shows me the types (github, etc.).

This also puts the user in some kind of decision paralysis… what place is the correct place to put that information? Again, for JS / TS / Rust developers, it would probably be ridiculous to detach that important information (type annotations, doc headers, section docstrings) into a separate file.

We also need a bunch of explanations and conventions on where and when to put docstrings. Not ideal.

We are currently doing just that: Thinking about a way to progress without disrupting any existing workflows. It’s not only a question of preference, it’s also important to think about ways to make the language more accessible and straight-forward to use.

3 Likes

@Hongbo, @ryyppy thank you for the replies :hugs:

Sorry, I’ve tried to explain the big picture in my head not focusing on particular details too much. Structuring thoughts is hard.

Sure. And sometimes this creates splits I mention. Remember the times when there were no Promises in NodeJS and the err-first callbacks were way to go. Then promises appeared and a new cohort of libraries has emerged. The only reason for the existence of many of them was that they are promise-based. Then the mature callback-based libraries continued to evolve one way and the new promise-based alternatives gone their way. And these ways don’t always match. Examples: pg vs pg-promise.

Now imagine, the later introduced async/await were not interchangeable with then/catch. That would be havoc for the ecosystem.

This may sound unprofessional but for several times I saw decisions about what framework/library a team picks up depend on its “style” rather than performance, size, and other important characteristics. That’s a life.

The Python world, for example, is addicted to their idioms and zens. There’s even a word “Pythonic” which any pythonist feels. And for the reason of such addiction, arguably, Python has a very solid community libs ecosystem.

I wish ReScript community the best. So, don’t want too many axes where libs/frameworks/teams might split.

I was trying to play a role of ReScript newcomer. Just imagined what questions he might ask himself.

I omitted it to keep the post short :rofl: In this context @obj external make is clunky enough to be used only for FFI. No one working in the pure ReScript land would use it for his structures, I guess.

This makes a lot of sense! But I see the new feature is so good that it would inevitably leak outside the interop scope. That’s the point I see problem in. And the example in my original post shows the different styles which will be possible which potentially “puts the user in some kind of decision paralysis”.

So, how could it be mitigated? I don’t know. A rough sketch:

module Delivery: {
  type t

  @obj
  type opts = {
    code: string,
    price: Money.t,
    tariffCode: option<string>,
    city: string,
    cityCode: option<string>,
    address: string,
    pickupPointCode: option<string>,
    comment: string,
  }

  let make: (~~opts) => t
}

// So that both syntaxes for call are equivalent
let d1 = Delivery.make({ code: "CDEK", price: Money.make(3.0, #eur),  /* ...  */})
let d2 = Delivery.make(~code="CDEK", ~price=Money.make(3.0, #eur),  /* ...  */})

Now, a library or package with the Delivery creates no questions for those who prefer object-style args and labeled args. The both teams can use it without emotional misery.

Furthermore, rescript format could enforce one or another style to put a period in bikeshedding. That would be great.

I don’t suggest to explode the @obj feature and postpone its release. I’m just asking to try to predict the future and show the vision in the form of further RFCs or something.


Yes, I am. I see they add a big value to the DX. Not the interface files per se, but a feature to have automatically checked and documented API contracts.

When I’ve discovered ReasonML it was a norm for a library README to say “no doc yet, see MyLib.rei. To my surprise, these *.rei were more than enough to understand the library and refer to them again and again while using the lib to recall stuff.

In contrast, any JS lib is much harder to understand and adopt if it is missing some kind of external docs.

Let me disagree here. I am a JS developer. And any project less trivial than a set of forms, menus, and other UI have a business logic domain. Many such projects have this logic on the front-end side, not back-end. I have two such ReScript examples in production.

And while the UI part, indeed, is more effective if we immerse in the implementation straight away; the business logic is different. A typical module might easily include a few dozens of API functions implemented in several KLOC. Here the signatures come to the rescue. Team mates and even authors of such modules return to the signature again and again to recall things, to restrict themselves from accidental API changes, etc. Such modules are often used the same way as 3-rd party libraies and signatures help a lot.

These are perfectly valid points. And I’m sure that export inlining and discouraging *.resi actually have more upsides than downsides. My only concern is loosing the “API + docs without implementation clutter” feature. Even if *.resi stay here but no one uses them because they are boring is equivalent to missing them.

To make things clear, I’m not a fan of having two files, I don’t like copy/paste type declarations, and I’m confused by all the things you mention as well. I just see an opportunity now to elaborate about introducing the inline exports and preserving the good parts of module signatures.

I haven’t come with an elegant way and asking the community to share the ideas. That’s it.

1 Like

Out of curiosity, why wouldn’t we use labeled arguments here? Both the arguments are strings. It’s a perfect illustration of the reason for labeled arguments in the first place–disambiguate between arguments of the same type. And even without labels, it’s the normal practice to put doc comments in interface files, so yes, of course you would comment about the function’s params.

This is not how JS developers work.

How developers work is largely a function of the language they use. In JS, they don’t have interfaces so of course they don’t use them. In ReScript, they do have interfaces and I’ve noticed in larger projects over time they do tend to write interface files.

To achieve real zero-cost in the compilation output, you literally need to copy the whole content from .res to .resi.

If the res and resi are exactly the same, there’s no point in having the resi, right?

and I don’t even know how we’d effectively represent that on the playground UI

Perhaps res and resi tabs immediately above the playground editor text area?

Currently our resi files are not properly highlighted on Github due to their rules that there must be a certain number of resi files on a lot of repositories

This is a chicken-and-egg problem. Same process was followed for .res files too, of course.

but I don’t want to create an interface file just for this one doc string. … This also puts the user in some kind of decision paralysis… what place is the correct place to put that information?

It sounds like the user already knows what the correct place is but doesn’t want to do it :slight_smile:

May I also point out that type annotations in interface files actually have a different meaning than ones in implementation files. Because the unification rules are different, if you want to ensure a generic type variable works correctly in an implementation file type annotation (i.e. doesn’t accidentally unify with another type because of an implementation mistake), you will almost certainly need an explicit type quantification (type a. ...). Whereas in interface files the generic type variables are already implicitly quantified. What looks simpler to read … code annotated with explicit type quantification? Or usual type variables in interface files? I would like to think the latter.

Anyway, if we step back from all this for a bit, as Hongbo said, resi files are not going anywhere:

So in the end, we will probably end up having to teach both ways of doing it.

4 Likes

I am a beginner so just my 2 cents and a quick idea on this. I like how signatures provide a dense/compact representation of what the module is supposed to do. Good for prototyping and thinking before implementation. A separate .resi file is cumbersome especially if it is only going to be used once. Each file already has a .bj.js and a .gen.ts and VS code can’t collapse/outline them. I wish I could do something like this…

// All inside Zoo.res
@file_signature
module type ZooType = {
type t
let addAnimal: (t, string) => t
let hasAnimal: (t, string) => bool
}

type t = list<string>
let addAnimal = (t: t, s: string) => List.cons(s, t)
let hasAnimal = (t: t, s: string) => t->List.exists(i => i == s, _)

…or

module type _ = {
...
1 Like

You kinda can :grin:

include ({
  type t = list<string>

  let addAnimal = (t: t, s: string) => List.cons(s, t)
  let hasAnimal = (t: t, s: string) => t->List.exists(i => i == s, _)
} : {
  type t

  let addAnimal: (t, string) => t
  let hasAnimal: (t, string) => bool
})
3 Likes

Hey, that looks almost like the export {} statement at the end of an ES module :thinking:

4 Likes

This syntax is a bit ugly but I’m going to give it a try.