RFC: More general type checking for structural typings

The problem with that in my opinion that in most cases you want the compiler to complain if you forgot/not included a field. Only if you have some kind of config object with many optional values the proposed behavior is much nicer.

Perhaps it should be two independent annotations? (Or a syntax like I proposed above where you explicitly signal that you want all the other optional fields to be set to None)

1 Like

Any movement on the original proposal from April?

Recently Typescript made a change to distinguish optional properties from those that exist with value undefined. Might relate to this.

I’d like to mention the positive affect on many newer Web API bindings this RFC would bring.

A common pattern on newer JS proposals is to “init objects”. This is a name I’ve made up to describe a pattern of passing a relatively large option bag as an object to class constructors. Examples include new Request({ body: 'hello' }) from the fetch API and Temporal.PlainTime.from({ hour: 5 }) from Temporal. I’ve taken the name from what TypeScript calls the types in the fetch declarations.

This pattern is even more crucial to Temporal, it uses it for methods too such as plainTime.add({ hours: 5 }).

This design pattern is currently very hard to type in ReScript, the best solution I’ve seen so far is what I call an options bag module:

module AddOptions = {
  type t

  @obj
  external make: (~hours: int=?, ~minutes: int=?, ~seconds: int=?, ()) => t = ""
}

@send external add: (t, AddOptions.t) => t = "add"

plainTime->add(AddOptions.make(~hours=5, ()))

This is really not an ideal DX - when using an API like this it takes a long time to figure out “ok this function accepts an AddOptions.t, how do I make one of those?”. It also bloats the bindings a huge amount which is a maintenance burden.

This would be greatly improved with:

@send
external add: (
  t,
  {"hours": option<int>, "minutes": option<int>, "seconds": option<int>},
) => t = "add"

plainTime->add({"hours": 5})
1 Like

I’ve just read back and seen this syntax is apparently in master? Cool! So will it be included in ReScipt 10?

I’d love the same feature for objects too.

1 Like

When installing the master branch as rescript you get a bunch of errors in other rescript packages.

For example, in rescript-react you get:

 We've found a bug for you!
  /Users/mando/Github/project/node_modules/@rescript/react/src/RescriptReactRouter.res:24:57-59

  22 │ 
  23 │ @send
  24 │ external replaceState: (Dom.history, @as(json`null`) _, @as("") _, ~hre
     │ f: string) => unit =
  25 │   "replaceState"
  26 │ 

  expect int, string literal or json literal {json|text here|json} 

FAILED: cannot make progress due to previous errors.

or


FAILED: cannot make progress due to previous errors.
Failure: /Users/mando/Github/project/node_modules/rescript/darwin/ninja.exe 
Location: /Users/mando/Github/project/node_modules/@rescript/react/lib/bs
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

~/Github/project demo !18 ?4                                                                                                                                                                                        
❯ yarn watch
yarn run v1.22.10
$ rescript build -with-deps -w
File "bsconfig.json", line 1
Error: package @rescript/react not found or built 
- Did you install it?
- If you did, did you run `rescript build -with-deps`?
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

~/Github/rescript-timer-hooks demo !18 ?5                                                                                                                                                                                     ✘ 2
❯ yarn watch
yarn run v1.22.10
$ rescript build -with-deps -w
Dependency on @ryyppy/rescript-promise
rescript: [1/5] src/Promise.iast
FAILED: src/Promise.iast

  We've found a bug for you!
  /Users/mando/Github/project/node_modules/@ryyppy/rescript-promise/src/Promise.resi:32:12-57:1

  30 │ external reject: exn => t<_> = "reject"
  31 │ 
  32 │ @ocaml.doc("
  33 │ `make(callback)` creates a new Promise based on a `callback` that recei
     │ ves two
   . │ ...
  56 │ ```
  57 │ ")
  58 │ @bs.new
  59 │ external make: ((@bs.uncurry (. 'a) => unit, (. 'e) => unit) => unit) =
     │ > t<'a> = "Promise"

  Offset: 260, Invalid escape code: "

rescript: [2/5] src/Promise.ast

I am probably misunderstanding what you mean by breaking change. Are these errors indicative of a breaking change or perhaps that the underlying package needs to be updated?

Thank you.

This is now being considered for the upcoming 10.0 release.
One possibility is to make @obj the default. The reason for this is that, for example, that will be eventually what’s used in JSX v4, when it ships (after 10.0). So it will be used in every component. The less magic one puts in the JSX, the better. Also, it seems that if one wants to give the choice at all, then one case should be the default, and the other one should be reserved for advanced users only. So that someone learning the language does not get entangled with such a distinction. And components (will) already take @obj as the default.

But, there are going to be tradeoffs. So curious to hear thoughts about this.
Perhaps more importantly, could changing the default break existing code? Is there code that relies on the field being there, with value undefined, vs it not being there?

6 Likes

The possibility to distinct between “absent” and undefined data has been introduced in Typescript 4.4. Even if IMHO is a bit silly that two different nil values are not enough (because, in practice, we would translate the pattern to something like option<nullable<'a>>), maybe someone showed some use cases on the issue/PR that led to this Typescript feature. Hopefully there are some interesting discussions that can be useful to better understand tradeoffs.

2 Likes

Is there code that relies on the field being there, with value undefined , vs it not being there?

There most certainly will be.

We don’t need to worry about destructuring: var { x = 1 } = { x: undefined } sets x = 1, so someone has to go through the hassle of Object.hasOwn(obj, 'x').

However, stuff like Object.keys() and co. will yield different results - and I would not be surprised if there’s some poor uses of that in the wild.

I have no strong opinion here, but I’d like to remind that records are not only used as a container for options that are, well, optional by definition. Instead, records are often used to represent a state data type encapsulated within a module.

In such a case, arguably, it’s more reliable to have a compiler that will bark on all the places where a newly introduced field happened to have an option<...> type is missing from the initializer. That is, without @obj behavior by default.

2 Likes

This aspect, of different typing behaviour w.r.t. normal records, is well understood. And surely one needs to evaluate its impact.
Instead, it seems that the dangers of changes in runtime representation are less well understood. It really depends on whether people do make use of the assumption about the data representation where missing values are fields present with undefined value. We know it’s possible. It would be interesting to know whether it’s actually happening in what people do.

These dangers need to be understood no matter what is the default. The only difference is how prevalent those dangers would be.

1 Like

In case of the options, I think {foo:undefined} is actually more likely to break something than {}.

I can think of only one common use case where this certainly makes a difference: when an object is used as a dictionary. In this case Js.Dict is usually more appropriate on the ReScript side. But even if record is used for some reason, omitting the property is probably the desired behaviour.

This variation on the original design was motivated by resolving some issues needed to support the upcoming JSX v4:

As a side effect, this also improves on several concerns raised in this discussion.

1 Like

It should be possible to ship this as experimental feature in the next release.

1 Like

Hello! It’s very nice to see the progress of @optional and how it became a more fine-grained @obj. Thank you for it!

I’d like to share a thought about making it even more concise and consistent with other parts of the language. Namely, the optional function args. I see they have somewhat common semantics, so could they have a common syntax as well?

With your implementation, a developer have the following opportunities:

type t = {
  x: int,
  @optional y: int, // this is how an optional field is declared
}

// basic usage, very intuitive
let v1 = { x: 3 }
let v2 = { x: 3, y: 4 }

// unwrapping an option
let v3 = { x: 3, y: @optional None }
let y: option<int> = Some(4)
let v4 = { ...v1, y: @optional y }

Wouldn’t it be nice if instead of @optional one can use the familiar ? token known from optional args?

type t = {
  x: int,
  y: int=?, // same as in function declarations
}

// basic usage, same as before
let v1 = { x: 3 }
let v2 = { x: 3, y: 4 }

// unwrapping an option
let v3 = { x: 3, y: ?None } // like in functions
let y: option<int> = Some(4)
let v4 = { ...v1, y: ?y } // like in functions
let v5 = { ...v1: ?y } // even punning might be supported

This is just a thought. I know, implementing such syntax is harder because it requires touching the syntax layer. Nevertheless, perhaps it would be possible some day in future, even after the current @optional way will be released and settled down.

6 Likes

Definitely putting a syntax layer on top is something worth considering .
What needs to happen is for someone to investigate how a dedicated syntax fits within the existing parser/printer, so a prototype can be shown to people.

1 Like

This is not a new concept, so familiarity with e.g. TypeScript or other languages is also something to consider.

1 Like

I’m all for this new syntax, it would be coherent with the existing rescript syntax and would be easily understood in my opinion.

I originally had the false impression that making @obj the default would mean that we would not be guided when adding new option fields, but given those fields have to explicitly annotated with @optional then I guess it’s alright!

For the breaking changes it would bring in the generated code, I guess it could be made more likely by the usage of if ('field' in obj) in typescript, but God what a brittle logic anyway, and it would not break existing code base given again you’d have to annotate the field.

As a last remark, the RFC has evolved from structural typing (for objects) to nominal typing (for records), shouldn’t we rename this thread?

2 Likes

Notice this proposal does not change existing code at all, so is non-breaking.
You only opt into semantic difference if you use @optional on some fields, but then it’s a different type altogether.

Renaming sounds like a good idea.

1 Like

Would this syntax ever work?

type t = {
  x: int,
  y: int = 3
}