Tl;DR
I am suggesting that Object should have the ability to be updated safely, and that open structural comparison should be easier for types.
I am also suggesting that Records and Objects should have a cleaner update mechanism.
More broadly, I am suggesting that Object/Records should have a more FP native way of updating to allow for more use cases and easier adoption of ReScript.
Use Case
I read through a lot of these forums, and typically it just comes down to someone saying, “use case” before any meaningful conversation can be had. So, let’s get that out of the way.
I am reviewing ReScript for production usage, and our current use case is to pass data to functions that only care about specific properties of that data and ignore all other properties (keys). This is a common practice in JavaScript, but more common in Clojure. The fancy term is Data-Oriented Programming. However, I think this is just part of Functional Programming.
The domain we are modeling is a user support system, which the ticket models alone can have multiple levels (I think 3 or 4 max) of nested data. (Not my choice FYI).
The idea behind the use case is that you may have a data that is structurally defined, but a specific function that does not care about the complete structural definition or if that definition changes. The function only cares about a specific portion of the data.
Concrete Example
To be more concrete. Imagine we have this person
type.
type person = {
"name":string,
"email":string,
"age": int,
"address": {
"street": string,
"city":string
}
}
Now imagine the data can be bigger than above, it could have former address, credit cards… all the great things are domain model requires to be stored and passed as data.
Now, a common thing for people to want to do is update thier email address.
Okay, in ReScript Objects are forever immutable, so we would probably want to model our data as a Record. So, we could build a function like:
// Made shorter for better reading, pretend there is a bunch of other properties
type person = {
email: string,
age: int,
}
let updateEmail = (p:person, newEmail:string) => {...p, email: newEmail}
As a side note, the ability to update Records is rather simplistic and leaves a lot wanting. If all your Records are 1 level deep, then this works fine, but as soon as you get to Records in Records, the above syntax is not a smooth update.
Updating Records done, but it doesn’t really meet my requirements. This updateEmail()
function is highly coupled to the data type person
. However, the business requirements may (and did) change. So instead of just person
, we now have a business
type and a reseller
type. These types are similar, but also different.
We could solve this problem making our Record more generic, make it cover all use cases, and some type of flag to identify between different entities.
This would create a new entity
type Record.
type entity =
| Person
| Business
| Reseller
type entity = {
email:string,
age: int,
address:address,
entity: entity
}
The consequence is that a person will have unused business properties, and vice versa.
Maybe the consequences are not acceptable. Maybe it does not make sense for the business logic. Maybe the entity logic gets more complex (e,g. there needs to be many categories of business). There could be a number of reasons why one would not want to do this, even the simple reason that they want their data to be modelled separately.
What is it does do is tightly couple your updateEmail()
function with the data type. If we ever wanted to update the email of anything, we cannot. We have to use an entity
type (previously a person
type).
To make updateEmail()
more reusable, and not connected to entity
directly, it would require updateEmail()
to only take data that has an email
property but doesn’t care about any other property. Which is not possible with Records, and as the documentation suggest, Object should be used.
So, we could write this with an object, but… oh no , we cannot update objects in a safe immutable way. ReScript has limited that.
In addition, creating a function to structurally compare is akward. You have to write a function like this:
let updateEmail = (obj:{.. "email":string}, updateEmail:string) {...}
The syntax {.. "email":string}
is not documented, difficult for bigger objects, and can cause some odd error messages see Issue #5594.
I am happy this syntax exists otherwise; we would be stuck. What I am suggest is that the ability to duck type with object types like in TypeScript would provide a better experience for those coming from JS/TS world.
(I currently have no suggestions beyond looking towards TypeScript)
My Current Solution
I require this structural typing; my current solution utilizes Deep Patch | Rimbu. It is rather ugly, and hard to sell ReScript to my company when something as simple as updating an Object safely is such a chore.
Here is my solution:
type person = {
"email": string,
"name":string,
"age": int
}
let me:person = {
"email": "sean@example.com",
"name": "sean",
"age": 32
}
@module("@rimbu/deep") external patch: (~state:'a, ~patch:'b) => 'a = "patch"
let updateEmail = (state:{.. "email":string}, update:{.. "email":string}) => {
patch(~state=state, ~patch=update)
}
Js.log(updateEmail(me, {"email":"updated@example.com"}))
The reason the property state
and update
have the same type {.. "email":string}
is to make sure that whatever Object you pass to update
will conform to the Object passed to state
.
It’s not perfect, and if someone has a better way, please let me know.
It would be nice if I could define a type that is open, and I could use that type across both state
and update
without having to rewrite the type signature twice.
Bonus points, if I could make updateEmail()
even more generic, something like updateProperty()
where the consumer could pass their own open type to the generic function updateProperty()
.
Like this in TypeScript:
updatePropety<{email:"string"}>(obj1, obj2)
This could probably be done with PPX (however the documentation around that is very outdated and lacking depth), but I could not after hours of testing, find away in ReScript alone.
What I Suggest
What I suggest is that ReScript provide a native way to safely update both Record and Object. I think both Immer and Deep Patch | Rimbu provide safe, efficient, and native feeling object copy/update.
Maybe ReScript could provide a special syntax operator, such as patch
, produce
, or update
. I imagine something like this:
let updatedState = update(state, (draft)=>{
draft["email"] = "new email"
})
Where in this case, draft
is a special mutable version of the Record or Object you passed into state
. This way we can update properties in a type safe fashion.
Of course, this should be done efficiently, meaning that only the updated properties are on the new returned object, every property that is a reference is still a reference.
This would probably break with Reason history, and even break with other JS compiled FP languages like Elm or PureScript. However, I think it would give ReScript an edge as updating objects in this matter feels imperative but is actually safe.
Summary
Whatever happens, updating objects today is a frustrating (boilerplate) experience, because the language was design not for the use case presented here. Updating Records is not much better.
Maybe the answer is “this is not a use case ReScript wants to cover”. That is okay. However, it kind of sucks because on my check list ReScript falls into that good place between TypeScript and PureScript. The only area it is missing is a way to safely update Objects.
(Also, apologies the word “poor” in my title was click-baity)