🍽

Next.js の Zod 活用術

2022/12/14に公開

本年は Next.js + バリデーションライブラリの Zod をよく利用し、Zenn でもいくつかの関連記事を投稿しました。本稿では、この組み合わせならではの TIPS を紹介します。記事で紹介するサンプルは以下に置いています。

https://github.com/takefumi-yoshii/nextjs-zod-gssp

リクエスト検証に便利な 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に推論されるため、postIdstring型であるかを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