Next.js の Zod 活用術
本年は Next.js + バリデーションライブラリの Zod をよく利用し、Zenn でもいくつかの関連記事を投稿しました。本稿では、この組み合わせならではの TIPS を紹介します。記事で紹介するサンプルは以下に置いています。
リクエスト検証に便利な Zod
Next.js で getServerSideProps を使用すると、リクエスト検証をサーバーサイドで行えます。例えばセッションに保持している値の検証はバリデーションライブラリの Zod を使用して、次のようなコードで実現できます。
export const userSchema = z.object({
name: z.string(),
email: z.string(),
});
export const getServerSideProps = async (ctx) => {
const sess = await getSession(ctx.req, ctx.res);
const parsed = userSchema.safeParse(sess.user);
if (!parsed.success) {
// ...未ログインの処理
}
// ...ログイン済みの処理
};
煩雑になりがちな getServerSideProps
/my/posts/[postId]
というルートの場合、URL に含まれる動的パスパラメーター[postId]
の検証がさらに必要になります。ctx.params
に含まれる値は例外なくstring | string[] | undefined
に推論されるため、postId
がstring
型であるかをparamsSchema
で検証しています。
const paramsSchema = z.object({
postId: z.string(),
});
export const getServerSideProps = async (ctx) => {
// (1) ユーザー検証
const sess = await getSession(ctx.req, ctx.res);
const checkLogin = userSchema.safeParse(sess.user);
if (!checkLogin.success) {
sess.redirectUrl = ctx.resolvedUrl;
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
// (2) リクエスト検証
const checkParams = paramsSchema.safeParse(ctx.params);
if (!checkParams.success) {
return { notFound: true };
}
// (3) データ取得
const data = await getMyPost(checkParams.data.params.postId);
if (!data) {
return { notFound: true };
}
return { props: data };
};
このように随所で Zod が活躍しますが、検証対象が増えるたびに getServerSideProps に書く処理は増えていきます。またプロジェクトのページが多い場合、処理詳細やエラーハンドリングが統一できていない、という課題がよく発生します。
合成関数による処理の統一
合成関数を使用すると、先ほどのgetServerSideProps
関数は次の例のようにまで簡略化できます。composeGssp((1)ユーザー検証Middleware, (2)リクエスト検証Middleware, (3)データ取得関数)
というように、2 つの Middleware とデータ取得関数を一つの getServerSideProps
関数に合成します。(ここで言及している Middleware とは Next.js v12.2.0 で stable になった middleware 機能とは別物です)
export const getServerSideProps = composeGssp(
withUser, // (1)
withZod(z.object({ params: z.object({ postId: z.string() }) })), // (2)
async (ctx) => ({ props: await getMyPost(ctx.params.postId) }) // (3)
);
この合成関数の図解は次のとおりです。合成関数の内部にエラーハンドラーを仕込んでいるため、各 Middleware からスローされた例外に応じてエラー画面を表示したり、ログイン画面にリダイレクトするような処理を施すことが可能です。
リクエスト検証 Middleware はリクエストパラメーターが期待値を満たさない場合、例外をスローしてエラー画面を表示します。Zod はバリデーションだけでなく、スキーマ定義から型推論を導きます。withZod
という Middleware 生成関数は、後続の ctx が Zod スキーマ定義通りであることを保証します。
export const getServerSideProps = composeGssp(
withUser,
withZod(z.object({ params: z.object({ postId: z.string() }) })),
// Zodスキーマ定義に連動するため ctx.params.postId の型推論は string型に
async (ctx) => ({ props: await getMyPost(ctx.params.postId) })
);
Zod スキーマ定義に連動した型推論はctx
が対象になります。次の例では、/my/posts/[postId]?page=1
というリクエストは通過しますが、/my/posts/[postId]?page=1&page=2
というリクエストは弾きます(検索クエリー値は配列にもなりうるということ)ctx.query.page
も期待通りstring | undefined
に推論されます。同様にctx.req.headers
の検証を行うこともできます。
export const getServerSideProps = composeGssp(
withUser,
withZod(
z.object({
params: z.object({ postId: z.string() }),
query: z.object({ page: z.string().optional() }),
})
),
async (ctx) => ({
// ctx は検証済みであり型推論に反映される
props: { postId: ctx.params.postId, page: ctx.query.page },
})
);
withUser
というユーザー検証 Middleware を適用すると、データ取得関数ではctx.user
が型推論とともに拡張されます。ユーザー ID を参照してデータ取得を行うようなシーンでも柔軟に対応することができます。
export const getServerSideProps = composeGssp(
withUser, // ユーザー検証 Middleware で ctx を拡張
withZod(
z.object({
params: z.object({ postId: z.string() }),
query: z.object({ page: z.string().optional() }),
})
),
async (ctx) => ({
props: {
userId: ctx.user.id, // ctx.user が推論される
postId: ctx.params.postId,
page: ctx.query.page,
},
})
);
検証 Middleware の数は制限がなく、適用順もページ単位で変更することができます。検証 Middleware は他にも、認可検証・リファラー検証などを自在に追加できるため、要件に応じて柔軟に対応ができます。
リクエスト検証は、このような合成関数を使用せずに Next.js v12.2.0 で stable になった middleware 機能でも実現できます。しかし、ページ単位で要件がバラバラで、細かい制御を page 実装に隠蔽したい場合、有効な手段だと考えます。
composeGssp 関数
このような宣言的な getServerSideProps 実装を実現しているのがcomposeGssp
関数です。非同期関数をパイプ関数で合成しているため、Middleware 数に制限がありません。合成関数が担っているのは主に 2 つの処理です。
- 複数 Middleware を直列実行し、最後にデータ取得関数を実行する
- 非同期関数でスローされた例外をキャッチし GetServerSidePropsResult を返す
export function composeGssp(...fns: Function[]) {
return async function (ctx: unknown) {
try {
return await fns.reduce(
(a, b) => a.then((c) => b(c)),
Promise.resolve(ctx)
);
} catch (err) {
if (err instanceof GsspError) {
return err.toGsspResult();
}
throw err;
}
};
}
GsspError という 拡張 Error クラスインスタンスをスローしている点がポイントで、この Error クラスインスタンスは GetServerSidePropsResult を返すメソッドを持ち合わせます。このハンドリングにより、Middleware 処理で不都合があった時、即座に redirect props や notFound props を返却することができます。
export class UnauthorizedGsspError extends GsspError {
statusCode = 401;
redirect: Redirect = {
destination: "/login",
permanent: false,
};
constructor(redirect?: Redirect) {
super("UnauthorizedGsspError");
if (redirect) {
this.redirect = redirect;
}
}
toGsspResult(): GetServerSidePropsResult<never> {
return {
redirect: this.redirect,
};
}
}
ユーザー検証 Middleware やリクエスト検証 Middleware を通過すると、ctx が型推論とともに拡張されるのが特徴です。型定義は関数のオーバーロードでなんとか実現しています(美しくはないので、もっと良い解法ご存じの方いらっしゃれば教えていただけると嬉しいです)
composeGssp 関数定義詳細
import { GetServerSidePropsResult } from "next";
import { GsspError } from "./error/gssp";
import { StrictCtx } from "./type";
type Ctx = StrictCtx;
type GsspResult = Promise<GetServerSidePropsResult<unknown>>;
type MaybePromise<T extends Ctx> = Promise<T> | T;
export function composeGssp<A extends Ctx>(
ab: (a: A) => GsspResult
): (a: MaybePromise<A>) => GsspResult;
export function composeGssp<A extends Ctx, B extends Ctx>(
ab: (a: A) => MaybePromise<B>,
bc: (b: B) => GsspResult
): (a: MaybePromise<B>) => GsspResult;
export function composeGssp<A extends Ctx, B extends Ctx, C extends Ctx>(
ab: (a: A) => MaybePromise<B>,
bc: (b: B) => MaybePromise<C>,
cd: (c: C) => GsspResult
): (a: MaybePromise<C>) => GsspResult;
export function composeGssp<
A extends Ctx,
B extends Ctx,
C extends Ctx,
D extends Ctx
>(
ab: (a: A) => MaybePromise<B>,
bc: (b: B) => MaybePromise<C>,
cd: (c: C) => MaybePromise<D>,
de: (d: D) => GsspResult
): (a: MaybePromise<D>) => GsspResult;
export function composeGssp<
A extends Ctx,
B extends Ctx,
C extends Ctx,
D extends Ctx,
E extends Ctx
>(
ab: (a: A) => MaybePromise<B>,
bc: (b: B) => MaybePromise<C>,
cd: (c: C) => MaybePromise<D>,
de: (d: D) => MaybePromise<E>,
ef: (e: E) => GsspResult
): (a: MaybePromise<E>) => GsspResult;
export function composeGssp<
A extends Ctx,
B extends Ctx,
C extends Ctx,
D extends Ctx,
E extends Ctx,
F extends Ctx
>(
ab: (a: A) => MaybePromise<B>,
bc: (b: B) => MaybePromise<C>,
cd: (c: C) => MaybePromise<D>,
de: (d: D) => MaybePromise<E>,
ef: (e: E) => MaybePromise<F>,
fg: (f: F) => GsspResult
): (a: MaybePromise<F>) => GsspResult;
export function composeGssp(...fns: Function[]) {
return async function (ctx: unknown) {
try {
return await fns.reduce(
(a, b) => a.then((c) => b(c)),
Promise.resolve(ctx)
);
} catch (err) {
if (err instanceof GsspError) {
return err.toGsspResult();
}
throw err;
}
};
}
合成関数向け Middleware
合成関数向け Middleware の実装ガイドラインは 2 つです。
- 引数の ctx を含むオブジェクトを返す
- GsspError を継承した例外をスローする
ユーザー検証 Middleware
session をチェックしてログインユーザーが取得できなかった場合、UnauthorizedGsspError
をスローします。合成関数がこのエラーをキャッチすると、redirect props が展開されます。
export async function withUser<Ctx extends StrictCtx>(ctx: Ctx) {
const sess = await getSession(ctx.req, ctx.res);
const parsed = userSchema.safeParse(sess.user);
if (!parsed.success) {
sess.redirectUrl = ctx.resolvedUrl;
throw new UnauthorizedGsspError();
}
return { ...ctx, user: parsed.data };
}
リクエスト検証 Middleware
ctx を Zod スキーマで検証し、違反があった場合NotFoundGsspError
をスローします。合成関数がこのエラーをキャッチすると、notFound props が展開されます。
export function withZod<T extends ZodSchema>(schema: T) {
return function withZodMiddleware<Ctx extends StrictCtx>(ctx: Ctx) {
const parsed = schema.safeParse(ctx);
if (!parsed.success) throw new NotFoundGsspError();
return { ...ctx, ...parsed.data } as z.infer<T> & Ctx;
};
}
まとめ
Zod と合成関数を使用すると、リクエストハンドリング実装を格段に簡略化することができます。また、Next.js v13.0.0 で beta の新しい app ディレクトリでもこのテクニックは利用できます。合成関数向け Middleware + Zod スキーマ定義に応じて ctx の型推論が変わる様子が面白いので、ぜひサンプルコードを試してみてください。
Discussion