Remix v2 の JsonifyObject と SerializeFrom について


こんにちは。フロントエンドエンジニアの辻です。

Gaji-Labo はスタートアップのプロダクト開発を支援する会社です。フロントエンド開発においては、特に React と Next.js を得意分野としています。
ただ React と Next.js に囚われることなく、スタートアップのご要望や技術スタックに合わせて、他の技術やフレームワークを取り入れる柔軟性も持ち合わせています。

はじめに

今回は Remix v2 について書いてみたいと思います。
JavaScript フレームワークの中でも Next.js は強固な盤石を持っていましたが、最近では Remix をはじめ、他のフレームワークも追随してきています。
弊社でも Remix を採用する機会がありました。その開発中に妙に詰まった点がありましたので、ログとしても残しておこうと思った次第です。

問題のあるコード例

npx create-remix@latest を実行して、生成された Remix プロジェクトに下記の変更を加えました。

app/types/user.ts を新規作成

export type User = {
  id: string;
  name: string;
  email: string;
  photo: string;
  createdAt: Date;
  updatedAt: Date;
};

app/components/userProfile.tsx を新規作成

import type { User } from "~/types/user";

type UserProfileProps = {
  user: User;
};

export const UserProfile = ({ user }: UserProfileProps) => (
  <div>
    <div>id: {user.id}</div>
    <div>name: {user.name}</div>
    <div>email: {user.email}</div>
    <div><img src={user.photo} /></div>
    <div>createdAt: {user.createdAt.getTime()}</div>
    <div>updatedAt: {user.updatedAt.getTime()}</div>
  </div>
);

app/routes/_index.tsx を下記のように変更

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { User } from "~/types/user";
import { UserProfile } from "~/components/userProfile";

export const loader = async () => {
  const user: User = {
    id: "",
    name: "",
    email: "",
    photo: "",
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  await fetch("https://user-info")
    .then((res) => res.json())
    .then((data) => {
      user.id = data.id;
      user.name = data.name;
      user.email = data.email;
      user.photo = data.photo;
      user.createdAt = new Date(data.createdAt);
      user.updatedAt = new Date(data.updatedAt);
    })
    .catch((error) => {
      console.error(error);
    });

  return json({ user });
};

export default function Index() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <UserProfile user={user} /> {/* …(※1) */}
    </div>
  );
}

さて、上記のコードを実装したところ、app/routes/_index.tsx の <UserProfile user={user} /> (※1)にて、型エラーが発生してしまいました。

エラー内容は下記の通りです。
createdAt について言及されていますが、updatedAt にも同様のエラーが発生します。

型 'JsonifyObject' を型 'User' に割り当てることはできません。
プロパティ 'createdAt' の型に互換性がありません。
型 'string' を型 'Date' に割り当てることはできません。

解決方法

先に解決方法を提示します。
今回のエラーは、app/components/userProfile.tsx を下記のように更新すれば解決できます。

import type { SerializeFrom } from "@remix-run/node"; // 新規追加
import type { User } from "~/types/user";

type UserProfileProps = {
  user: SerializeFrom<User>; // 更新
};

export const UserProfile = ({ user }: UserProfileProps) => (
  <div>
    <div>id: {user.id}</div>
    <div>name: {user.name}</div>
    <div>email: {user.email}</div>
    <div><img src={user.photo} /></div>
    <div>createdAt: {new Date(user.createdAt).getTime()}</div> {/* user.createdAt が string 型に推論されるので new Date() を経由します */}
    <div>updatedAt: {new Date(user.updatedAt).getTime()}</div> {/* user.updatedAt も user.createdAt に同じです */}
  </div>
);

エラーの原因

useLoaderData 関数が返す user が User 型ではなく、JsonifyObject<User> 型になっているのが原因です。
UserProfile コンポーネントでは props.user は User 型である事が期待されていますが、問題のコード例では JsonifyObject<User> 型であるため、型の不一致が発生しています。

ここで登場した JsonifyObject<T> 型とは、Remix 内部で使われている型であり、「JSONに変換可能な型」を表します。

Remix の json 関数は Content-Type: application/json の HTTP レスポンスを返します。したがって、json 関数の引数に指定したオブジェクトは、一度、文字列に変換されます。
そして、useLoaderData 関数を使って loader 関数の返り値を取り出す際、文字列からオブジェクトを復元します。
この復元のタイミングで、元の型がそのまま適用されるのではなく JsonifyObject<T> 型が適用されます。
これが user が JsonifyObject<User> 型になっていた理由です。

先程の解決方法では、Remix の SerializeFrom<T> を利用する事で「User 型のオブジェクトが JSON に変換されると、どのような型になるか」を推論しています。
もともと Date 型である createdAt と updatedAt が、最終的に string 型で推論されたのは、このためですね。

別の解決方法

ちなみに今回のコード例で言えば、createdAt と updatedAt を Date 型ではなく、string 型にしても解決できます。
下記が createdAt と updatedAt を string 型にした場合のコード例です。この場合は SerializeFrom<T> を使う必要もなくなります。
要は createdAt と updatedAt を loader 関数の時点から string 型として扱い、最終的にコンポーネントで展開するタイミングで Date 型として扱う作戦ですね。

export type User = {
  id: string;
  name: string;
  email: string;
  photo: string;
  createdAt: string; // Date型 から string 型に変更
  updatedAt: string; // 同上
};
import type { User } from "~/types/user";

type UserProfileProps = {
  user: User;
};

export const UserProfile = ({ user }: UserProfileProps) => (
  <div>
    <div>id: {user.id}</div>
    <div>name: {user.name}</div>
    <div>email: {user.email}</div>
    <div><img src={user.photo} /></div>
    <div>createdAt: {new Date(user.createdAt).getTime()}</div>
    <div>updatedAt: {new Date(user.updatedAt).getTime()}</div>
  </div>
);
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { User } from "~/types/user";
import { UserProfile } from "~/components/userProfile";

export const loader = async () => {
  const user: User = {
    id: "",
    name: "",
    email: "",
    photo: "",
    createdAt: "2024/1/1", // 日付の初期値を入れておく
    updatedAt: "2024/1/1", // 同上
  };

  await fetch("https://user-info")
    .then((res) => res.json())
    .then((data) => {
      user.id = data.id;
      user.name = data.name;
      user.email = data.email;
      user.photo = data.photo;
      user.createdAt = data.createdAt
      user.updatedAt = data.updatedAt
    })
    .catch((error) => {
      console.error(error);
    });

  return json({ user });
};

export default function Index() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <UserProfile user={user} />
    </div>
  );
}

まとめ

以上、Remix v2 で妙に詰まった点でした。
Remix 公式ドキュメントには JsonifyObject<T>SerializeFrom<T> に関する情報が掲載されておらず、VSCode の型定義ジャンプで追いかけたり、X(旧 Twitter)の情報を参照して、やっとこさ解決に漕ぎ着けました。

もし、同じ点で困っている方がいらっしゃいましたら、参考になれば幸いです。

Gaji-Labo は React と Next.js を得意としていますが、今回の記事にある Remix や他の技術にも柔軟に対応できます。
プロダクトごとの状況によっては、さまざまな技術がわかるエンジニアが必要になることがあると思います。
Gaji-Laboはそういったスタートアップのお困りごとを支援する会社です。

フロントエンド開発のお困りごとは、ぜひご相談ください!

この機会に、オンラインで気軽に面談してみませんか?

現在弊社では一緒にお仕事をしてくださるエンジニアさんやデザイナーさんを積極募集しています。まずはカジュアルな面談で、お互いに大事にしていることをお話できたらうれしいです。詳しい応募要項は以下からチェックしてください。

パートナー契約へのお問い合わせもお仕事へのお問い合わせも、どちらもいつでも大歓迎です。まずはオンラインでのリモート面談からはじめましょう。ぜひお気軽にお問い合わせください!

お問い合わせしてみる!

投稿者 Tsuji Atsuhiro

フロントエンドエンジニア。 DTP・Webデザイナーを経験した後、フロントエンドエンジニアに転向。HTML/CSS/JavaScriptを中心にWeb開発を担当してきました。 UI・UXに興味があり、デザイン・コーディング両面から考えられるデザインエンジニアを目指しています。 普段はマラソンやボクシングなどで体を動かしてます。