DEV Community

Victor Trofin
Victor Trofin

Posted on • Edited on

2 1

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)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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>
}

Enter fullscreen mode Exit fullscreen mode

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
  }
}, [])

Enter fullscreen mode Exit fullscreen mode

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.

Image of Timescale

Timescale – the developer's data platform for modern apps, built on PostgreSQL

Timescale Cloud is PostgreSQL optimized for speed, scale, and performance. Over 3 million IoT, AI, crypto, and dev tool apps are powered by Timescale. Try it free today! No credit card required.

Try free

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Explore a sea of insights with this enlightening post, highly esteemed within the nurturing DEV Community. Coders of all stripes are invited to participate and contribute to our shared knowledge.

Expressing gratitude with a simple "thank you" can make a big impact. Leave your thanks in the comments!

On DEV, exchanging ideas smooths our way and strengthens our community bonds. Found this useful? A quick note of thanks to the author can mean a lot.

Okay