38
Covering the edge case on the ReScript record type
In terms of preference, Typescript dominates the category for statically typed languages that compile to plain JavaScript. However, ReScript - a fairly new contender in this category - aims to improve the JavaScript experience and address some of the pitfalls. In this article I'm planning on looking at the edge case in one of the most heavily used data types for each of the two languages. How different is working with records in ReScript than working with objects in TypeScript?
As opposed to TypeScript objects, records in ReScript are immutable by default. Another big difference is that records use nominal typing while objects use structural typing. What this means is that two records sharing the same properties (field names) will not have the same type.
For example, in TypeScript this code compiles. I'm able to provide an argument of type SubscriptionUser to a function that accepts an argument of type QueryUser simply because they have the same properties.
// .ts
type QueryUser = {
age: number
name: string
}
type SubscriptionUser = {
age: number
name: string
}
const logMyUser = ({ age, name }: QueryUser) => {
console.log(`Hi! I am ${name} of ${age} years old.`)
}
const subscriptionUser: SubscriptionUser = {
name: "John",
age: 30,
}
logMyUser(subscriptionUser)
This however will not work for records.
// .res
type queryUser = {
name: string,
age: int,
}
type subscriptionUser = {
name: string,
age: int,
}
let logMyUser = ({name, age}: queryUser) => {
Js.log(`Hi! I am ${name} of ${age->Js.Int.toString} years old.`)
}
let subscriptionUser: subscriptionUser = {name: "John", age: 30}
logMyUser(subscriptionUser)
// ERROR:
// [E] Line 17, column 10:
// This has type: subscriptionUser
// Somewhere wanted: queryUser
This means that different record types with the same properties cannot be passed to the same function. The main benefit for this is that type error messages are really good and point you to the specific line of code where you need to address the issue. Anyone who has used the structurally typed polymorphic variants in ReScript may know that sometimes it is difficult to pinpoint the exact location in your codebase where you need to address the type error. You may have to do a little bit of digging to figure out where that somewhere
might be.
This has type `x`
Somewhere wanted type `y`
Types for method `z` are incompatible
So, why does this matter? Well, because updating deeply nested records is a little more tedious. For example, consider the case of having a front-end react app with a graphQL query to fetch users, with reactivity provided via graphQL subscriptions. Every time an user gets updated we need to map over all of our users stored in state and replace the old values with the updated ones.
In TypeScript you would just assign the updated nested object and be done with it.
// Page.ts
import React, { useState, useEffect } from "react"
type QueryUser = {
name: string
age: number
}
type SubscriptionUser = {
name: string
age: number
}
type QueryResult = {
id: string
userData: QueryUser
}
type SubscriptionResult = {
id: string
userData: SubscriptionUser
}
// assume we have an array of users fetched from a GraphQL api
const someMagicWayToGetData = (): QueryResult[] => {
const users = [
{ id: "1", userData: { name: "John", age: 35 } },
{ id: "2", userData: { name: "Mary", age: 20 } },
{ id: "3", userData: { name: "Kate", age: 50 } },
]
return users
}
// and a graphQL subscription to push updates to our page
const someMagicWayToGetUpdates = (): SubscriptionResult => {
const updatedUser = {
id: "2",
userData: { name: "Mary Jane", age: 21 },
}
return updatedUser
}
const Page = () => {
const [
users,
setUsers
] = useState<QueryResult[]>(someMagicWayToGetData())
const updatedUser = someMagicWayToGetUpdates()
useEffect(() => {
const newUsers = users.map((user) => {
if (user.id === updatedUser.id) {
return {
...user,
userData: updatedUser.userData,
}
}
return user
})
setUsers(newUsers)
}, [updatedUser])
return (
<div>
<ul>
{users.map(({ id, userData: { name, age } }) => (
<li key={id}>
User: {name}; Age: {age}
</li>
))}
</ul>
</div>
)
}
export default Page
In ReScript you need to manually assign each updated property within the nested record. This will not compile.
// .res
let newUsers = users->Js.Array2.map(user => {
if user.id == updatedUser.id {
{...user, userData: updatedUser.userData}
} else {
user
}
})
// Error:
// [E] Line 43, column 20:
// This has type: subscriptionUser
// Somewhere wanted: queryUser
This compiles
// Page.res
type queryUser = {
name: string,
age: int,
}
type subscriptionUser = {
name: string,
age: int,
}
type queryResult = {id: string, userData: queryUser}
type subscriptionResult = {id: string, userData: subscriptionUser}
let someMagicWayToGetData: unit => array<queryResult> = () => {
let users: array<queryResult> = [
{id: "1", userData: {name: "John", age: 35}},
{id: "2", userData: {name: "Mary", age: 20}},
{id: "3", userData: {name: "Kate", age: 50}},
]
users
}
let someMagicWayToGetUpdates: unit => subscriptionResult = () => {
let updatedUser = {
id: "2",
userData: {name: "Mary Jane", age: 21},
}
updatedUser
}
@react.component
let default = () => {
let (users, setUsers) = React.useState(_ => someMagicWayToGetData())
let updatedUser = someMagicWayToGetUpdates()
React.useEffect1(() => {
let newUsers = users->Js.Array2.map(user => {
if user.id == updatedUser.id {
{
...user,
userData: {
name: updatedUser.userData.name,
age: updatedUser.userData.age,
},
}
} else {
user
}
})
setUsers(_ => newUsers)
None
}, [updatedUser])
<div>
<ul>
{users
->Js.Array2.map(({id, userData: {name, age}}) =>
<li key=id> {`User: ${name}; Age: ${age->Js.Int.toString}`->React.string} </li>
)
->React.array}
</ul>
</div>
}
It might not look like much of a hassle in this example but in the real world, a graphQL query result with lots of nested records may become a little bit annoying to deal with.
However, the ReScript docs do provide a good alternative for this situation, and a better way to handle this case is to represent the userData as a combination of a variant and record instead. This could look like this:
// .res
type user = {
name: string,
age: int,
}
type userData = {
id: string,
userData: user,
}
type result =
| QueryResult(userData)
| SubscriptionResult(userData)
let users: array<result> = [
QueryResult({
id: "1",
userData: {name: "John", age: 35},
}),
QueryResult({
id: "2",
userData: {name: "Mary", age: 20},
}),
QueryResult({
id: "3",
userData: {name: "Kate", age: 50},
}),
]
let updatedUser: result = SubscriptionResult({
id: "2",
userData: {name: "Mary Jane", age: 21},
})
let newUsers = users->Js.Array2.reduce((allUsers, user) => {
switch (user, updatedUser) {
| (
QueryResult({id}),
SubscriptionResult({id: subscriptionId, userData: subscriptionUserData})
) if id == subscriptionId =>
allUsers->Js.Array2.concat([
QueryResult({
id: id,
userData: subscriptionUserData,
}),
])
| (QueryResult(user), _) => allUsers->Js.Array2.concat([QueryResult(user)])
| (_, _) => allUsers
}
}, [])
I chose the reduce method in the example above to have less cases to handle in the switch pattern but definitely an array map would also work. Only thing is that we'll have to handle a few more cases and it looks a little too verbose to me, even though that is the recommended way to go.
Wonderful, isn't it? This ended up looking a lot nicer than I was expecting. I can definitely see the benefits of using the nominally typed records instead of objects even for deeply nested data structures. By the way, if you actually prefer it, the structurally typed object exists in ReScript as well but it is more suitable to be used for binding to JavaScript objects.
38