RFC: More general type checking for structural typings

Not in ReScript, no. One JS library off the top of my head is LocalForage. It’s uses a config object and just checks if the object properties exist or not. Setting the fields to None/undefined crashes it. I’ve recently bound to it in ReScript with @obj for optional fields. This proposal would definitely make it more convenient though.

Would a similar thing also be considered for records?

What would be completely amazing if the syntax between structurally typed objects and records could be unified, and that whether a record is structural or nominal can be maybe set with an annotation on the type? Probably I it is super hard to implement this, but that would be a dream scenario.

Anyway this change would be pretty great for a lot of things when binding to JS. Especially a lot of API’s have a configuration object with a lot of optional values, having a creator function with optional arguments creates a lot of boilerplate code, especially if the objects are nested (which they often are). So I like this proposal!

4 Likes

To elaborate: This is very useful when authoring bindings and binding to JS object types with many optional fields. This is usually the case for config objects as you described. Another use case would be React Native style objects.

In JS, such styles (contained in a StyleSheet object) look like this:

const styles = StyleSheet.create({
  text: {
    textAlign: "center",
    marginTop: 64.0,
    padding: 20.0
  },
  screen: {
    flex: 1.0,
    backgroundColor: "white"
  },
});

In ReScript, we currently cannot have records or structural objects with optional fields (that can just be left out instead of set to None).

So in the current ReScript React Native bindings, we need to model style objects using an abstract type and @obj external ... if we want zero-cost bindings. This way, the ReScript equivalent to the above JS code currently looks like this:

open Style

let styles =
  StyleSheet.create({
    "text":
      style(
        ~textAlign=`center,
        ~marginTop=dp(64.0),
        ~padding=dp(20.0),
        (),
      ),
    "screen": style(~flex=1.0, ~backgroundColor="white", ()),
  })

Ideally (though that would be a major breaking change for the bindings) we would want the style definitions to be as close to the JS version as possible (without having to resort to a PPX solution).

E.g.

let styles =
  StyleSheet.create({
    "text": {
      "textAlign": `center,
      "marginTop": dp(64.0),
      "padding": dp(20.0)
    },
    "screen": {
      "flex": 1.0,
      "backgroundColor": "white"
    },
  })
1 Like

Would a similar thing also be considered for records?

That would make the performance model a bit hard to reason about (V8 IC), we will see how this proposal goes first.

having a creator function with optional arguments creates a lot of boilerplate code,

This is inspired from that, I saw some functions which has hundreds of arguments, this is clearly not what we want.

1 Like

Is this because the shape of the object changes when you leave out the undefined values? I think just having the shorter syntax would already be a big win. Maybe compressing the undefined values out could be a separate feature, or available just on objects?

it’s mostly when bindings to JS libs that use option objects.

For example today I’m binding this with:

  type t

  @obj
  external make:
    (
      ~ownerConnectionString: string=?,
      ~retryOnInitFail: bool=?,
      ~watchPg: bool=?,
      ~pgDefaultRole: string=?,
      ~dynamicJson: bool=?,
      ~setofFunctionsContainNulls: bool=?,
      ~classicIds: bool=?,
      ~disableDefaultMutations: bool=?,
      ~ignoreRBAC: bool=?,
      ~ignoreIndexes: bool=?,
      ~includeExtensionResources: bool=?,
      // ~showErrorStack: bool or 'json',
      // ~extendedErrors: any combo of ['hint', 'detail', 'errcode'],
      ~handleErrors: array(GraphQlError.t) => array(GraphQlError.t)=?,
      ~appendPlugins: array(GraphileBuild.plugin)=?,
      ~prependPlugins: array(GraphileBuild.plugin)=?,
      ~replaceAllPlugins: array(GraphileBuild.plugin)=?,
      ~skipPlugins: array(GraphileBuild.plugin)=?,
      ~readCache: string=?,
      ~writeCache: string=?,
      ~exportJsonSchemaPath: string=?,
      ~exportGqlSchemaPath: string=?,
      ~sortExport: bool=?,
      ~graphqlRoute: string=?,
      ~graphiqlRoute: string=?,
      ~externalUrlBase: string=?,
      ~graphiql: bool=?,
      ~enhanceGraphiql: bool=?,
      ~enableCors: bool=?,
      ~bodySizeLimit: string=?, // human readable e.g. 200kb or 5MB
      ~enableQueryBatching: bool=?,
      ~jwtSecret: string=?,
      ~jwtVerifyOptions: JWTVerifyOptions.t=?,
      ~jwtRole: string=?,
      // ~jwtAudiences: string=?, // deprecated
      ~jwtPgTypeIdentifier: string=?,
      ~legacyRelations: string=?, // only, deprecated, or omit
      ~legacyJsonUuid: bool=?,
      ~disableQueryLog: bool=?,
      ~pgSettings: Express.Request.t => Js.Dict.t(string)=?,
      ~additionalGraphQLContextFromRequest: Express.Request.t =>
                                            Js.Dict.t(string)
                                              =?,
      ~pluginHook: pluginHookOutput=?,
      ~simpleCollections: [ | `omit | `only | `both]=?,
      ~queryCacheMaxSize: int=?,
      ~simpleSubscriptions: bool=?,
      ~subscriptions: bool=?,
      ~subscriptionAuthorizationFunction: string=?,
      ~readOnlyConnection: string=?,
      ~defaultPaginationCap: int=?,
      ~graphqlDepthLimit: int=?,
      ~graphqlCostLimit: int=?,
      ~exposeGraphQLCost: bool=?,
      ~graphileBuildOptions: GraphileBuildOptions.t=?,
      ~live: bool=?,
      ~allowExplain: unit => bool=?,
      unit
    ) =>
    t
}

Being able to just use a simple rescript object would definitely be nicer, especially when you have nested config objects.

2 Likes

This sounds great!
Our backend (Node) relies heavily on Pulumi an AWS SDK (js libs). Both usually use config objects with lots of optional properties.
Currently we use @obj everywhere. This feature could simplify our bindings and usage.

1 Like

I think for the objects using structural typing this would be a good feature to have – but I think as others have pointed out there is definitely a desire for the ability to specify Exact types like in flow. TypeScript has had a feature request for exact types for 4+ years now https://github.com/microsoft/TypeScript/issues/12936#issue-195716702

Personally, I like the confidence that records always have the runtime representation that they’re defined with. Maybe this is an edge case for others but I have quite often run into runtime problems with TypeScript where the structural typing fails in conjunction with GraphQL’s runtime type checking – we have mutation inputs that are similar in shape to query results for our forms, and if the __typename property is not deleted from the mutation input (form values being initialized from query results) we get a runtime error. I like being able to point at nominal typing with records as an example of ReScript giving us additional type safety.

2 Likes

I think exactness as defined in TypeScript or Flow is about limiting additional fields on an object, while this proposal is about allowing fewer fields on an object. ReScript has exactness check on objects today and this proposal would not affect it. Both Flow and TS have a concept of optional fields where leaving them off or setting as undefined is valid. They are related but different enough that I think it’s worth mentioning. Here’s a small example of both.

2 Likes

This would allow for pretty massive changes to the way JSX works. Today <Foo prop1=1 /> abstractly compiles to jsx(Foo.make, Foo.makeProps(~prop1=1, ())). With this change it could compile to something more like jsx(Foo.make, {"prop1": 1}). This means that React components are just one value instead of make and makeProps. It would greatly simplify the @react.component part of the jsx transform to the point where really the only thing it does is provide component names and handle default props.

19 Likes

Just out of curiosity, do you see components still using labelled arguments (as opposed to an object)? Seems like labelled args would provide better ergonomics (assuming no object destructuring syntax… which maybe isn’t a safe assumption? :smiley: )

Labelled arguments are still going to be used, the underlying transformation would be improved.

Edit: this is referring to the call site, not declaration.

1 Like

We’ll see! =)
The goal is to have as little of a ppx transform as possible, ideally none, since even a tiny ppx vs no ppx is still a world of difference in terms of tooling support and impedance mismatch.

4 Likes

I have another approach to solve this use case (already an incorrect POC made) using nominal types.
So you can do something like this:

type config = { 
   x : int ,
   y : option < int >,
   z : option < int> 
}
let fn = (c : config) => {
   ...
}
fn ({x : 3 }) // The type system allows optional labels to be initialized with None for optional types

Does this cover your use case? This feature alone is useful in other scenarios?

12 Likes

This approach for record types would be great! Even if the JS output is fn({ x: 3, y: undefined, z: undefined}) (to preserve performance predictability), this is a great win for usability of record types with lots of optional fields.

1 Like

Yeah that solution would be really nice @Hongbo !

4 Likes

I think we can make it configurable per type, so that user can choose do display with undefined or not, we will check how much effort it needs to be done

3 Likes

That would be amazing @Hongbo. I think a lot of libraries already have these records (I have a lot of bindings that look like that), so with this improvmeent we can just drop the factory functions with optional labeled arguments.

4 Likes

Hi all,
I believe most technical challenging problems are solved with this proposal,
we can have lots of innovations with nominal typed records.
Would be happy to hear if you have some more innovative ideas. (Note this is a non breaking enhancement)

Here is a complex demo for preview:

type t0 = {
  x : int ,
  y : option<string>
}

let h = { x : 3 }
module N = {
  type c = option<string>
}

module Make = () => {
  type t<'a> = {
    x: int,
    @as("Y") y: option<string>,
    z: option<'a>,
    h: N.c,
  }
}

module N0 = Make()
open N0

let f = {
  x: 3,
}

Generated JS code:

'use strict';


var N = {};

function Make($star) {
  return {};
}

var N0 = {};

var h = {
  x: 3,
  y: undefined
};

var f = {
  x: 3,
  Y: undefined,
  z: undefined,
  h: undefined
};
18 Likes

Great to hear that this proposal is making good progress. :+1:

However, considering that config objects or React props objects can have a huge number of (optional) fields, I think the generated code should just omit the optional fields instead of setting them to undefined.

Otherwise this would not really be a viable replacement for @obj for these use cases IMHO.

3 Likes