⚠︎

Next.js の Error を丁寧に扱う

2021/09/13に公開

Next.js には組み込みのエラーフォールバック機構が存在します。pages/404.tsxpages/500.tsx、Unhandled Error を捉えるpages/_error.tsxが組み込みフォールバックです。https://nextjs.org/docs/advanced-features/custom-error-page

実アプリケーションにおいてはこれだけでは不十分なケースが多く、意図的なもの・そうでないものをハンドリングしログ収集に繋げるなど、きちんとエラー設計をしたいところです。

TypeScript 4.4 で try catch の推論が変更になった

話が少しそれますが、TypeScript 4.4 で try catch 文の catch 引数errの推論がanyからunknownに変更になりました。この変更はuseUnknownInCatchVariablesフラグの設定で有効となり、strict: trueによる一括 strict 指定対象にも含まれます。そのためこれまでコンパイルが通っていたコードが、4.4 にアップグレードした途端エラーだらけになってしまった、という人も少なくないと思います。(以下 TypeScript のリリースブログより)

// 4.3 以前
try {
  // Who knows what this might throw...
  executeSomeThirdPartyCode();
} catch (err) {
  // err: any
  console.error(err.message); // Allowed, because 'any'
  err.thisWillProbablyFail(); // Allowed, because 'any' :(
}

例外 throw は Error インスタンス以外も可能であり、catch するのは Error インスタンスのみとは限りません。err引数がanyunknownと推論されるのはこのためで、型安全に処理を継続するためにはinstanceof演算子による評価でガードを施すことが妥当です。

// 4.4 以降
try {
  executeSomeThirdPartyCode();
} catch (err) {
  // err: unknown
  // Error! Property 'message' does not exist on type 'unknown'.
  console.error(err.message);
  // Works! We can narrow 'err' from 'unknown' to 'Error'.
  if (err instanceof Error) {
    console.error(err.message);
  }
}

try catch 文で throw される例外は catch 文で再 throw しない限り、例外処理がそこで中断してしまいます。例えば非同期処理から返ってくるエラーを期待しているコードだとしても、実際には想定していなかった例外も発生しえます。そのため意図しない例外を捉えた場合、再 throw するのが得策でしょう。

Next.js データ取得関数内でのエラー処理

TypeScript 4.3 以前でコンパイルが通っていた以下のデータ取得関数を、4.4 にアップグレードしたとします。GetStaticProps型を使用している場合、いくつかのコンパイルエラーが発生することでしょう。any推論されていたため気づかなかった荒が見えた瞬間です。

type Props = {
  data?: Data;
  status?: string;
};
export const getStaticProps: GetStaticProps<Props> = async () => {
  try {
    const data = await fetch("http://api.com/path/to/api").then((res) => {
      if (!res.ok) throw new Error(`${res.status}`);
      return res.json();
    });
    return { props: { data } };
  } catch (err) {
    // Error! オブジェクト型は 'unknown' です
    return { props: { status: err.message } };
  }
};

まずunknownでコンパイルエラーとなっている箇所のerrインスタンスを評価します。しかしこれだけだと戻り値がvoidになるケースが発生し、GetStaticProps型の制約を満たしません。

  } catch (err) {
+   if (err instanceof Error) {
      return { props: { status: err.message } };
+   }
  }

そこで catch 文の末尾で再 throw を行います。これにより意図していなかった例外処理を中断することなく、戻り値がvoidになることも無くなりました。型推論とコンパイルエラーがここまで実装を矯正してくれたという、良い例です。

  } catch (err) {
    if (err instanceof Error) {
      return { props: { status: err.message } };
    }
+   throw err
  }

余談ですが、この末尾で throw された例外は pages/_error.tsx に着地しますので、Unhandled Error として Component 内でエラーログ収集すると良いでしょう。プロダクションビルドでは到達して欲しくないログ、という事になります。

カスタムエラーでより丁寧に

err引数の評価はinstanceof演算子で行うため、拡張したエラークラスインスタンスを throw すると便利です。以下のとおり fetch 関数内において 400・500 系エラーが返ってきた場合、独自に拡張定義したHttpErrorインスタンスを throw してみます。

try {
  const data = await fetch("http://api.com/path/to/api").then((res) => {
-    if (!res.ok) throw new Error(`${res.status}`);
+    if (!res.ok) throw new HttpError(res);
    return res.json();
  });
  return { props: { data } };
} catch (err) {
-  if (err instanceof Error) {
-    return { props: { status: err.message } };
-  }
+  if (err instanceof HttpError) {
+    return { props: { err: err.serialize() } };
+  }
  throw err;
}

このカスタムエラーは Next.js のデータ取得関数から Component に props で情報を渡せる様に、シリアライズ関数を含めています(class インスタンスを渡す事はできないため)err.serialize関数でHttpErrorObjectを出力します。エラー収集時なども、このクラスが提供する詳細情報が活きるでしょう。

export type HttpErrorObject = {
  name: string;
  message: string;
  stack?: string;
  http: {
    url: string;
    status: number;
    statusText: string;
  };
};
export class HttpError extends Error {
  url: string;
  status: number;
  statusText: string;
  constructor(response: Response) {
    super(response.statusText);
    this.name = "HttpError";
    this.status = response.status;
    this.statusText = response.statusText;
    this.url = response.url;
  }
  serialize(): HttpErrorObject {
    return {
      name: this.name,
      message: this.message,
      stack: this.stack,
      http: {
        status: this.status,
        statusText: this.statusText,
        url: this.url,
      },
    };
  }
}

最終的に Page コンポーネントは以下の様になりました。

type Props = {
  data?: Data;
  err?: HttpErrorObject;
};
export const getStaticProps: GetStaticProps<Props> = async () => {
  try {
    const data = await fetch("http://api.com/path/to/api").then((res) => {
      if (!res.ok) throw new HttpError(res);
      return res.json();
    });
    return { props: { data } };
  } catch (err) {
    if (err instanceof HttpError) {
      return { props: { err: err.serialize() } };
    }
    throw err;
  }
};
const Page = ({ data, err }: Props) => {
  if (err) return <Error {...err} />;
  return <Template {...data} />;
};
export default Page;

フォールバックに利用される Error コンポーネントは、HttpErrorObjectの型定義を参照することができるので、型観点でも扱いやすいです。

import type { HttpErrorObject } from "@/error";
import styles from "./style.module.css";

export const Error = ({ name, message, http }: HttpErrorObject) => {
  return (
    <div className={styles.module}>
      <h1>{name}</h1>
      <p>{message}</p>
      <div>
        <p>{http.status}</p>
        <p>{http.statusText}</p>
        <p>{http.url}</p>
      </div>
    </div>
  );
};

この一連処理のもう一つの利点は、HttpErrorインスタンス以外を再 throw する点です。try 文に含まれる処理で HttpError 以外の Error が発生した場合、Unhandled Error として_error.tsxが捉えますから、考慮漏れなどに起因するエラーにいち早く気付くことができます。今回は fetch 関数を使ったサンプルになりますが、この様な下処理があることでエラー処理を手厚くすることが可能です。エラー設計に関するアプローチとして、一つのアイディアとして投稿しました。

Discussion