[ANN] Enhanced Ergonomics for Record Types

I have an interesting real-world use case at my $dayjob:

I work with a suite of web-applications which interact with each other. For Items created in one upstream app that needs to be made available in a downstream app, the workflow is as follows:

  1. Create the item on the app and store it in upstream app’s database where id for item is generated.
  2. Fetch the catalog of items in upstream app to edit/modify items
  3. Configure it to make it available for downstream app
  4. Fetch the catalog of items in downstream app exposed by upstream app
// Fictional code for my use case
module Item = {
  // apps
  type a = A1 | A2 | A3
  // data
  type d

  // base
  type base = {
    name: string,
    description: string,
    data: d,
    createdBy: a,
    isAvailableDownstream: bool
  }
  // for upstream catalog use
  type upstream = {
    id: int,
    name: string,
    description: string,
    data: d,
    isAvailableDownstream: bool
  }
  // for downstream catalog use
  type downstream = {
    id: int,
    name: string,
    description: string,
    data: d,
    createdBy: a,
    isAvailableDownstream: bool
  }
}

This could be easily represented (in future possibly) by:

module Item = {
  // apps
  type a = A1 | A2 | A3
  // data
  type d

  // base
  type base = {
    name: string,
    description: string,
    data: d,
    createdBy: a,
    isAvailableDownstream: bool
  }
  // for upstream catalog use
  type upstream = base & { id: int } ! { createdBy: a }
  // for downstream catalog use
  type downstream = base & { id: int }
}

Of course, there are some more fields that are added and removed from the base type for upstream and downstream types but, for the sake of communicating the idea I have simplified the use case.

The app I maintain is in ReScript and the others are Typescript or JavaScript.
Currently we maintain all the required type variations in different codebases. For ReScript, it would be syntactic sugar to introduce these field add/remove operations for Records.

1 Like

Got it. And you don’t need any functions that operate on these (similar but different) types?

Using phantom types would be rather elegant to transform from e.g. base to upstream (and so forth), but I believe you’d then still have the issue of getting the matching record contents for each respective item type. You could potentially combine it with GADTs, but even after that I don’t think you could conveniently spread the contents of one record to another.

Anyway, in your above (albeit simplified) example, you’d at least be able to come a bit closer using the record type spreads (using the alpha or beta release):

module App = {
  type t = Foo | Bar | Baz
}

module Item = {
  type commonProps = {
    name: string,
    description: string,
    data: string,
    isAvailableDownstream: bool,
  }

  type base = {
    ...commonProps,
    createdBy: App.t,
  }

  type upstream = {
    ...commonProps,
    id: int,
  }

  type downstream = {
    ...commonProps,
    createdBy: App.t,
  }
}

https://rescript-lang.org/try?version=v11.0.0-alpha.5&code=LYewJgrgNgpgBAQQA5LgXjgbwFBzgFwE8l5904AxEEOAHzgCEBDAJzsaYC9sBfbbUJFhwAkvhjByOPERJwAxiGCgAdgAUWIJAGcpuPHBVNgMAFxxt+FgEsVAcwA0+vGBjb5NpPmsgV5yzb2TgZwYEz4TP5Wto7OcNbaCABuTNZQTABGsAAiIADuKgEwxuYZ1FDBcHz6svAZTNrwGNIGAHTtisq+GlralXgexeJgDITmyEit+JXVMsTwEDpWxZLNce2tnao9Ov3xYOa20-qzBPOh+YXLxnohG1vdmrtxg+EwI2OIKFMzvPywZHqjTEEnMIOArSBTSw+iMJnMACIALKEOBQ+LiYAIyqudyeby+REo0JuDzWLw+FTY-RhCKIgBm1GpeASyVS6SyMFyBSKJTg9KYUEalVew1G42+VBATmqALgi15wHBYMxrQV11WMLwcLMcGRqPVLBWGIkzJJePJBL8euJuLJFN8ZtpkT1jJAZtZKTSmRyl0V5gFQpglWsBzgAEYZf8YGQwH6NcrRKq4zyNbdDMZdfqLqmjTdrJinaT8ZSiai7SXHTjwi6EW6PYkvRzfbmVgHBcL9KL3uKvpMpVGgA

2 Likes

Only a function or two to transform the fetched item, specifically, to derive/build/use the data field.

Yup, after the beta or the final v11 release, I should be able to narrow down to the most common props/fields and build/expand the types.

By the way, is this supposed to work (i.e. spreading into inline records)?

type commonProps = { name: string, description: string }

type t = 
  | Foo({ ...commonProps, a: string })
  | Bar({ ...commonProps, b: string })

Can you expand on how you currently approach this?

After the data is fetched, I use Recoil to hold the data:

Webapi.Fetch.fetch(url)
->Promise.then(item => item->Item.getData->resolve)
->Promise.then(data => setIntoDataRecoilAtom(_ => data))
->ignore

And then some Recoil Selectors to derive state from data, which have the form:

dataRecoilAtom->Item.getStats

The type d I mentioned earlier is usually a big JSON object.

1 Like

I couldn’t help but try this, I’m not sure it’s feasible in a real-life application, but fun nevertheless. Also, there’s likely room for improvement.

/* Example use */

let newBaseItem: Item.t<Item.base> = Item.make(
  ~name="My item",
  ~description="My description",
  ~data="foo",
  ~createdBy=App.Foo,
)

Item.view(newBaseItem).createdBy->ignore /* has createdBy */

let newUpstreamItem: Item.t<Item.upstream> = Item.makeUpstream(
  newBaseItem,
  ~id=1,
)

Item.view(newUpstreamItem).id->ignore /* has id */

let newDownstreamItem: Item.t<Item.downstream> = Item.makeDownstream(
  newBaseItem,
)

Item.view(newDownstreamItem).createdBy->ignore /* has createdBy */

let upstreamItem: Item.t<Item.upstream> = newBaseItem->Api.upstreamItem

/* Illegal */
// Item.view(newBaseItem).id /* not available for base */
// Item.view(newUpstreamItem).createdBy /* not available for upstream */
// Item.view(newDownstreamItem).id /* not available for downstream */

// newUpstreamItem->Api.upstreamItem /* not allowed */
// newDownstreamItem->Api.upstreamItem /* not allowed */

https://rescript-lang.org/try?version=v11.0.0-beta.1&code=LYewJgrgNgpgBAQQA5LgXjgbwFBzgFwE8l5904AxEEOAHzgCEBDAJzsaYC9sBfbbUJFgFi8AJL4YwcjjxEScAMYhgoAHYAFFiCQBnGbjxw1TYDABccXfhYBLNQHMANIbxgYuxXaT5bINZbWdo4uRnBgTPhMgTb2zq5wtroIAG5MtlBMAEawACIgAO5qQTCmllnUUKFwfIby8FlMuvAYskYAdJ3Kqv5aOrrVeF6lkmAMhJbISO341bVyonAQejal0q0Jne3d6n16g4lglvazhvMiCmCFxaumBmFbO73a+wnDkTBjE4goM3P8CwU+AAPAByJgAPgBcFgZGATAA1hY4AAKBIAPxMZhiwXiYXR7k83l8-hxcQOBMi0SssRCGPeo3Gk1+pzwAEp0BCCMDGs0oYZYXB4UiAKorFhrSwokG8mAQpxwdG2I6JNT4DloLkg5YlUxQvCC4UwfJFXXASwyppyzncq6m27AfkGmBkFK2GAFC1gyE28G8fiCaDiSTmuASKT3C7wJ6aF76DZhLHIoLkhKEry2Hx+AI03EHCJRMl0sJJVLpTI5Y3XM3lSr-OqLWWRvCPFS7OMHBmfJk-aasmrQ+pLcVrZtwVs9WP9A7K45q+uA+B2m4Su4JjpdNvPadvVeM75TP5nQeLCWKbngrloBL0ZjNFGytle2X6oz0MVmlE6h1P7nf1eOjecAmiuawosuZq-iCEEOk6MIukKiItKimKmDACoEh4GZZv4GEFkwGFdl8GpcneMAom0eBJvmWHEtm+ZUjOyRpBk2R5NWDqWAAZkwUDNJ2e7doQcxstChpIR+DrkCiZEUdR4S0ZmJJqAq+EKgA+jwbIYcqJFwJJAEUQk1FpopOEqWmjEJMqTFlqxlYgTWBAsBA6FnKJAoIUajlSRgMlWkZYREeMBwmWE6Z0bhllRCJek+YZlFKIJXyhWhNFEkp9HRQR1nMeWbFVvaAEWi5bl4FpYkIW6HrmAkQ62CGAAqojtNyDVSM1JBXly7XAJ1LTWSGnIJHgugFA1igABaJENiV4LeAUpHpKQjewBlgUtNorWE83ARxhmbZqcDbTtfC1IGwjILYkaCv+azhuscCyg9NoPe0RrraYD5Wg9OlgGgACMom1AA9AAVHAACiAAephIMIEDNHAYMg-wgpqB6ZEPZYb0gm9L7kG9RpongqFmGgABEACyhAzVIFPVJhGXmZTNMKczykM4YlJRJTXHUFzpPBYQaCHlQIAuB5b3VQUKIYwUWMhmy2zJeMAC0EK2A4aggBKcDg3Ak1NElIxCcjqPYOjHqfcA2NhiGMzAm9d16oTDsfSOX2GPLitSIzyqA5L-DS+6svyzbD3K8qGtazresG0b+jKubaMIfL8X3SGOMO3jDswQBV721I71IRnXtUZjP0hkH2Ahx6csemXttKyrptfDH2u6-ACfG8LKeWwhLvN1I2fF7nxdD4XPtV1IGtXe0Q8PfwBtiFAsAOLxKcgyDRfAO0MsNwrM-AFHYD6xDOtkEwLEVsI-NsE2KPYNvu-76Hh8Ry3fcG5fcDX-llY4D32HGaLeO865h0bvtTOUhT7n2MCAK+N8CpAN1uEaBdwn7Px3uHT2w9gBzyQLYBeeCXo-0QX-NehRPhgOMFAoqMCCEQnnovIa5Cr5UIKDQp+QA

2 Likes

Your GADT solution is pretty cool, but I have a question…do you really need the GADT here? Of course, the code you show in the playground is just an example and you may have more functions in mind that really need the gadt…but if you just have the make and the view functions, why not separate the different items into their own types, eg BaseItem, UpstreamItem, DownstreamItem, etc? (Unless you are needing to pack different item types into a collection using an existential…)

Anyway, cool solution, just curious about your intended use.

Probably doesn’t work out of the box. Are there useful scenarios for it? Given that you can’t get a hold of the resulting definition and coerce it.

Happy to explore it if there are concrete use cases.

Yeah, that approach is probably preferable!

Mostly because I still think of these as being of the same type, just in different states (and their transition paths are known), so there was an itch to represent them as such.

I think it’s easier to represent these as separate types in a structural language like TypeScript because you could easily have functions that allow you to operate on any of them.

EDIT: And just to see if I understood you correctly, you meant module BaseItem, module UpstreamItem and so on?

Blockquote
Mostly because I still think of these as being of the same type, just in different states (and their transition paths are known), so there was an itch to represent them as such.

Your thought is spot on - they are the same type, in different states for different uses and follow different transition paths. However, I am not familiar with GADTs :sweat_smile: and there’s no documentation for GADTs in the ReScript Language manual. I might have to start reading up some OCaml documentation.

Once I understand what you intended to achieve with your solution, I might be able to tell you if it was feasible or even proper to use it for the use case I presented.

1 Like

You could get rid of the phantom types (although, on second thought, I’m not sure they qualify as phantom types in my last example) and ”just” use GADTs, but @Ryan’s suggestion is still likely the most pragmatic approach.

Oh I wasn’t trying to say your GADT approach wasn’t the right one, rather just interested in your use case. But yeah, managing data in different states and their transitions with gadt sounds reasonable to me.

(Yeah I was talking about unique types for each item …baseitem, upstreamitem, etc., but just as a thought experiment rather than a suggestion. In fact, I’m not sure which is the nicest solution given that I don’t know the other constraints.)

@sprkv5: It might be worth opening a new thread about this if you keep going with the gadt approach. Would be an interesting discussion I think.

2 Likes

I didn’t take it as such at all! I just think that in general it pays off to be less ”clever”.

Agree, it’d be interesting to explore further.

2 Likes