Next.js の Error を丁寧に扱う
Next.js には組み込みのエラーフォールバック機構が存在します。pages/404.tsx
とpages/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
引数がany
やunknown
と推論されるのはこのためで、型安全に処理を継続するためには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