🎄

App Router 時代のエラーハンドリング / TypeScript一人カレンダー

2024/12/21に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の15日目です。昨日は『Vitest test-d.ts で複雑な型をテストする』を紹介しました。

2年前からのエラーハンドリングの変化

2年前のカレンダーにてErrorの取り扱いについて紹介した記事では、TypeScriptプログラミング上のエラーハンドリング方法として、Errorクラスを継承したカスタムエラークラスを作り、if (e instanceof CustomError)で分岐する手法を紹介しました。これはTypeScriptプログラミングにおいて今でも大筋で有効です。

class CustomError extends Error {
  name = "CustomError";
}

// ...

try {
  // なんらかのエラーハンドリングしたい処理
} catch (e) {
  if (e instanceof CustomError) {
    // CustomError が投げられたらハンドリング
  }
  throw e;
}

しかし、この2年の間にWebアプリケーションの開発現場には変化がありました。Next.jsApp Routerを採用しているプロジェクトでは、エラーハンドリングに気をつけなければならない場面が増えたのです。アプリケーション側でRSC(React Server Components)とClient Componentが混在している際に、状況によってはエラーオブジェクトのクラス情報が失われてしまうという状況が生まれています。そして失われるかどうかはNext.js側の事情であるためTypeScriptコンパイラのグリーン/レッドでは気付くことができません。

App Routerでのエラーハンドリングの難しさ

筆者が担当している案件でもNext.js App Routerを使っています。App Router下では、クライアント側("use client"指定)のコンポーネントがthrowしたエラーであれば、error.tsxで受け取ったときにerror instanceof CustomErrorが期待通りtrueになります。

"use client";

import type { ReactElement } from "react";

import { CustomError } from "./custom-error";

export function ClientComponent(): ReactElement {
  throw new CustomError(); // use client 環境でのコンポーネントが CustomError を throw
  return <div>client component</div>;
}

この挙動は直感的で、特に疑問に思うものではありません。

"use client";

import type { ReactElement } from "react";

import { ErrorPage } from "./error-page";
import { CustomError } from "./custom-error";

type Props = Readonly<{
  error: Error & { digest?: string };
  reset: () => void;
}>;

export default function ErrorRoot({ error, reset }: Props): ReactElement {
  console.log(error instanceof CustomError); // true

  // なんらかのエラーメッセージ表示するコンポーネント群
  return <ErrorPage error={error} reset={reset} />;
}

一方、サーバーサイド(RSC)の処理で発生したカスタムエラーは、サーバーとクライアントを跨ぐ段階でプロトタイプ情報が失われるため、クライアントでハンドリングする際はerror instanceof CustomErrorfalseになってしまいます。

import type { ReactElement } from "react";

import { CustomError } from "./custom-error";

export default function PageRoot(): ReactElement {
  throw new CustomError(); // RSC 側から CustomError を throw
  return <div>hello</div>;
}
export default function ErrorRoot({ error, reset }: Props): ReactElement {
  console.log(error instanceof CustomError); // false 情報が失われている!

  return <ErrorPage error={error} reset={reset} />;
}

つまり、同じエラーを投げたつもりでも、RSC経由で来たエラーはブラウザ側でinstanceof判定が使えず、カスタムクラスのインスタンスであるという情報を失ってしまいます。TypeScriptプログラミングでカスタムエラーを活用していた人には問題となる事態です。ここで初めて「instanceofには信用できないケースがある」と気付かされます。

この問題は、TypeScript自体の習得だけでなく、Next.jsやReactに関するフレームワーク的な知識を要するため難解さがあります。同じTypeScriptという言語で開発を続ける際も、時代の変化や使用する技術スタックの変化に伴い、エラーハンドリング技法そのものもまた、習得と対応が必要であると認識することになります。

e instanceof に頼らないハンドリングへの回帰

instanceofが状況次第で使えなくなるとわかった以上、Next.jsプロジェクト上でのinstanceofの信頼性は低下してしまい、この手法に依存するエラーハンドリング箇所は再検討する必要があります。たとえばe instanceof CustomErrorではなく、エラークラスのメッセージに必ず名前が含まれるように実装し、e instanceof Error && e.message.includes('CustomError:')というJavaScriptの古典的な文字列マッチングに戻るという、素朴なハンドリング手法を選ぶことも候補に入ります。

export class CustomError extends Error {
  constructor(message: string) {
    // メッセージに名前を含めておく
    super(['CustomError', message].join(':'))
  }
}

try {
  // エラーが throw されるかもしれない処理
} catch (e) {
  // 名前込みのメッセージは変化しないので判定に使える
  if (e instanceof Error && e.message.includes('CustomError:')) {
    // なんらかのハンドリング
    return;
  }
  throw e;
}

これはJavaScript全盛の時代からの伝統的な手法であり後退してしまった感もありますが、少なくともRSCの利用や、フレームワーク側の最適化に身を任せるNext.js App Router下では安全性が高まります。クライアントからのエラーなのか、サーバーからのエラーなのか決め打ちができない環境下でinstanceofが期待通りになってくれない可能性があるのなら、最初からinstanceofを捨てて常に確実なパターンでエラーを判定するほうが、予想外の事故を防げます。

筆者の担当するプロジェクトでは、まだ全箇所ではありませんが、危険と判断できる箇所はすべてmessage.includes()に置き換える作業を済ませました。そして部分的にinstanceofが残っている箇所についても、なぜ後回しにしても安全なのかというアーキテクチャ上の理解をチーム内で周知させました。

RSCからClient componentへのインスタンス渡しの問題

クラス情報が消えてしまったり、クラスが活用できないという場面は他にもあります。

RSCからClient ComponentにProps経由でインスタンスを渡そうとすれば、TypeScriptコンパイラ上は通ったとしても、Next.jsの実行時にエラーを投げます。これもclassのインスタンスやカスタムエラーといったオブジェクトを境界越しに渡せない点です。

Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.

Server Actionsでも同様の制約があり、Server Actionsの規則に合致する非同期関数がクラスのインスタンスを返そうとすると、上記と同じ内容のエラーとなります。

早期発見の仕組みのために点検を

このようなフレームワーク固有の事情は、TypeScriptコンパイラだけの検証であればグリーンになってしまう点が厄介で、npm run devで動作させたりビルドしたりする際にようやく判明します。すなわち、TypeScriptへの理解だけでなく、フレームワーク自体やWebアプリケーションの一般的な仕組みに関する理解が不可欠なことに気付かされます。

また、CIのパイプラインをどう整備するかにも係ってきます。もしPRをマージするタイミングで毎回ビルドの検証を実施せずに、定期的な数日ごとリリースでしかビルドしないという場合、うっかりしていると数日間誤りに気付かないままという事態も想定できます。よって、CIで日常的に早期発見できるかという避難訓練的な備えが求められます。不安な方は、一度お使いのフレームワークがデプロイ時にTypeScriptコンパイルエラー以外にもどんなエラーを出しうるか、点検してみるとよいでしょう。

エラーハンドリングは最優先で習得すべきスキル

このような状況を踏まえると、エラーハンドリング技能は単なる言語技能の習得よりも比重の大きなものであるとわかります。新しいフレームワークやランタイムの仕組みを理解しつつ、それに合わせてそのつど時代に合わせたエラーハンドリングの戦略を柔軟に検討しなければなりません。

筆者が若手開発者のプロジェクト立ち上げをサポートする際、まず強調するのは「エラーハンドリングの基盤を最初に設計せよ」ということです。そして次に「そのエラーハンドリングに対してテストが書けるか」を説きます。適切なエラーハンドリングは、フレームワークが進化しアプリケーションが複雑化する中でも、プロジェクトを支え続ける基礎中の基礎となるのです。この記事が日頃のエラーハンドリングの意識への啓蒙に繋がれば幸いです。

明日は『実例 Result<T, E>』

本日は『App Router 時代のエラーハンドリング』を紹介しました。明日は『実例 Result<T, E>』を紹介します。それではまた。

Discussion