💿

【Remix】サーバー側の型をクライアント側で型定義に使うと型エラーになる

2023/09/17に公開

サーバー側で使うために定義した型をクライアント側のコードで使うと型が一致しない。クライアントで使う型をSerializeFrom<SomeServerType>にすると一致する。


ReactフレームワークのRemixを個人開発で愛用している。

Remixの魅力の一つは、サーバーからクライアントまでEnd-to-Endで型チェックができること。
以下のように、サーバーからクライアントに渡される値の型をクライアント側コードで自動的に推論できる。
そのため、開発時にクライアントとサーバーの間の型の対応づけに気を揉む必要がない。

type Post = {
  id: number,
  createdAt: Date,
  title: string
}

export async function loader({ request }: LoaderFunctionArgs) {
  // サーバーサイドのコード。prismaでDBからpostデータを取り出し、jsonにしてクライアントに送る。
  const posts: Post[] = await prisma.post.findMany();
  return json({ posts });
}

export default function Page() {
  // クライアント側で実行されるUIのコード
  // 実行時には、変数loaderDataに上で定義したloaderの返り値が格納される。
  // loaderDataの型が自動的にloaderの返り値の型 { post: Post[] } と推論されるため、
  // 開発時にクライアントとサーバーの間の型の対応づけに気を揉む必要がない
  const loaderData = useLoaderData<typeof loader>();
  return (
    <>
      <ul>
        {loaderData.posts.map(post => (
            <li>
            <span>{props.post.title}</span>
            </li>
          ))}
      </ul>
    </>
  );
}

しかし、ルートコンポーネント (上記の例だとPage) の一部を別コンポーネントに切り出したいとき、そのコンポーネントのpropsの型をサーバー側で使っている型 (上記の例だとPost)にしてしまうと、ルートコンポーネント内で型エラーが出る。

export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await prisma.post.findMany();
  return json({ posts });
}

// postのリストのうちアイテムだけを別コンポーネントに切り出し
// props.postにはloaderで使っているのと同じPostを指定
function MistypedPost(props: { post: Post }) {
  return (
    <li>
      <span>{props.post.title}</span>
    </li>
  );
};


export default function Page() {
  const loaderData = useLoaderData<typeof loader>();
  return (
    <>
      <ul>
        {loaderData.posts.map(post => (
          <MistypedPost key={post.id} post={post} /> // ここでpostの型が違うというエラー
          ))}
      </ul>
    </>
  );
};

起こるエラーは以下

Type 'JsonifyObject<{ id: number; createdAt: Date; title: string; content: string | null; }>' is not assignable to type '{ id: number; createdAt: Date; title: string; content: string | null; }'.
  Types of property 'createdAt' are incompatible.
    Type 'string' is not assignable to type 'Date'.ts(2322)

v1だと (多分) 以下

Type 'SerializeObject<UndefinedToOptional<{ id: number; createdAt: Date; title: string; content: string | null; }>>' is not assignable to type '{ id: number; createdAt: Date; title: string; content: string | null; }'.
  Types of property 'createdAt' are incompatible.
    Type 'string' is not assignable to type 'Date'.ts(2322)

本記事ではこのエラーの原因と解決策を紹介する。

原因

このエラーが起こるのは、loader関数の中で返している変数の型 (サーバーでの型) と、useLoaderData で得られるloaderDataの型 (クライアントでの型) が厳密には違うからである。

サーバーではオブジェクトをJSON.stringifyしてクライアントに送るため、その過程で一部の方はプリミティブに変形される。例えばDateオブジェクトはstringに変換される。
loaderDataはその変換を考慮した型になるため、サーバーの型とは一致しない。
例えば上の例だと、ルートコンポーネントPageの中でMistypedPostのpropsに渡しているpostの型はPostではなく、PostcreatedAtDateからstringに変わった型なのだ。

このクライアント/サーバーでの型の差異によって、サーバーでの型Postをそのまま外部コンポーネントMistypedPostのpropsの型とすると、ルートコンポーネントでloaderData内のそのコンポーネントに与える時に型が合わず、エラーが出る。

解決策

探すのに割と苦労したが、DiscordのhelpやXの投稿に答えがあった。

SerializeFrom<Post>で、Post,createdAtのDate型をstringに変換した型が得られるので、それをコンポーネントのpropsの型に使えば良い。

import type { SerializeFrom, ... } from '@remix-run/node'; \
...

// SerializeFrom<Post>で、Post,createdAtのDate型をstringに変換した型が得られる
function CorrectlyTypedPost(props: { post: SerializeFrom<Post> }) {
  // this will NOT cause a type error because the SerializeFrom converts the
  // Date type to string
  return (
    <li>
      <span>{props.post.title}</span>
    </li>
  );
};
...

以上です。

本記事で使ったコードの全体は↓に置いています。

SerializeFromは私が見たかぎりRemix公式のチュートリアルにもドキュメントにも載っていないのだが、なぜなのか。
「サーバーからクライアントまでEnd-to-Endで型チェックができること」を公式でも魅力の一つとして推しているので、上記の問題の解決策はもっと前面に出したほうが良いと思うのだが...。
解決策を見つけたXの投稿やDiscord、こちらのブログ記事など、同じ問題に当たっている人はいそうだし。

余談

そもそも、サーバー側でDate型のものはDate型のままクライアントで使いたいよね。

それを叶えるためにremix-typedjsonというライブラリが作られている。

これを使えばクライアントとサーバーで型が完全に一致するので、当然、本記事で取り上げた型エラーの問題も起こらない。

ただちょっと使ってみた感じ、remix v2.0.0で少し変な挙動をしている? (この記事を書いた日に2.0.0が公開されたから当然) ので、とりあえずサードパーティライブラリに頼らない解決法を見つけたかった。

Discussion