ReScript レコード タイプのエッジ ケースをカバーする


好みという点では、Typescript は、プレーンな JavaScript にコンパイルされる静的に型付けされた言語のカテゴリを支配しています.ただし、このカテゴリのかなり新しい候補である ReScript は、JavaScript エクスペリエンスを改善し、いくつかの落とし穴に対処することを目的としています.この記事では、2 つの言語のそれぞれで最も頻繁に使用されるデータ型の 1 つで、エッジ ケースを検討する予定です. ReScript でレコードを操作することは、TypeScript でオブジェクトを操作することとどのように異なりますか?

TypeScript オブジェクトとは対照的に、ReScript のレコードはデフォルトで不変です.もう 1 つの大きな違いは、オブジェクトが構造型付けを使用するのに対し、レコードは名義型付けを使用することです.これが意味することは、同じプロパティ (フィールド名) を共有する 2 つのレコードが同じ型を持たないということです.

たとえば、TypeScript では、このコードはコンパイルされます.タイプ QueryUser の引数を受け入れる関数にタイプ SubscriptionUser の引数を提供することができます.これは、それらが同じプロパティを持っているためです.

// .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)



ただし、これはレコードには機能しません.

// .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



これは、同じプロパティを持つ異なるレコード タイプを同じ関数に渡すことができないことを意味します.これの主な利点は、タイプ エラー メッセージが非常に優れており、問題に対処する必要がある特定のコード行を示していることです. ReScript で構造的に型付けされた polymorphic variants を使用したことがある人なら誰でも、型エラーに対処する必要があるコードベース内の正確な場所を特定するのが難しい場合があることを知っているかもしれません.その somewhere がどこにあるかを把握するには、少し掘り下げる必要があるかもしれません.

This has type `x`
Somewhere wanted type `y`
Types for method `z` are incompatible


では、なぜこれが問題なのですか?深くネストされたレコードを更新するのは少し面倒だからです.たとえば、graphQL サブスクリプションを介して提供される反応性を使用して、graphQL クエリを使用してユーザーをフェッチするフロントエンドの反応アプリを使用する場合を考えてみましょう.ユーザーが更新されるたびに、状態に保存されているすべてのユーザーをマップし、古い値を更新された値に置き換える必要があります.

TypeScript では、更新されたネストされたオブジェクトを割り当てるだけで完了です.

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



ReScript では、ネストされたレコード内で更新された各プロパティを手動で割り当てる必要があります.これはコンパイルされません.

// .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



これはコンパイルします

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



この例ではそれほど面倒に見えないかもしれませんが、実際には、多数のネストされたレコードを含む graphQL クエリの結果を処理するのが少し面倒になる場合があります.

ただし、ReScript のドキュメントでは、この状況に対する優れた代替手段が提供されています.このケースを処理するより良い方法は、代わりに、バリアントとレコードの組み合わせとして userData を表すことです.これは次のようになります.

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



上記の例では、switch パターンで処理するケースを少なくするために reduce メソッドを選択しましたが、配列マップも確実に機能します.唯一のことは、さらにいくつかのケースを処理する必要があることです.これは推奨される方法ですが、私には少し冗長に見えます.

素晴らしいですね.これは、私が予想していたよりもはるかにきれいに見えました.深くネストされたデータ構造であっても、オブジェクトの代わりに名目上型付けされたレコードを使用することの利点は明らかです.ちなみに、構造的に型付けされたオブジェクトは ReScript にも存在しますが、JavaScript オブジェクトへのバインドに使用する方が適しています.