😈

Next.jsのsearchParamsはas stringせずに必ずバリデーションしてくれ。またはvalibotのちょいテクニック

2024/11/18に公開

Next.jsのsearchParamsの型問題

Next.jsのsearchParamsの型は少々厄介です。searchParamsのドキュメントでは次のように型定義が記載されています。

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const filters = (await searchParams).filters;
}

各パラメーターの型がstring | string[] | undefinedとなっていますね。これを使うときに型チェックが面倒になってsearchParams.filters as stringと書いてしまっているのをよく見ます。string[]になるのは、次のように同じパラメーターキーを複数指定したURLの場合です。

/search?filters=foo&filters=bar

別の悪い例としては、そもそもsearchParamsが実際に届き得る値よりも狭い型定義をしてしまうことです。例えば次のようなコンポーネント定義です。

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{
    q: string;
    sort: "asc" | "desc";
  }>;
}) {
  // ...
}

sortはまるで必ず"asc""desc"が届くかのような型定義になっていますが、実際はサイト訪問者のURL手入力によりsortstringどころかstring[]にもundefinedにもなり得ます。しかし上記のコードでは取りうる値が型に反映されていないため、コード上は特に考慮しなくても型チェックは通ってしまうでしょう。qも同様にstringだけでなくstring[] | undefinedの考慮ができていません。

stringがくる前提の処理にstring[]undefinedが届けば、高確率でランタイムエラーになることでしょう。それは500エラーとなり、エラーログが汚染され、エラーは無視される習慣になるかもしれません…。

searchParamsの型をごまかすだけで、外からエラーを発生させることが可能になってしまいます。それを避けるためにもsearchParamsはランタイムでバリデーションすべきです。

ところで、パスパラメーターのparamsはランタイムチェックしなくてもセーフです。なぜならparamsはページコンポーネントのファイル名から型が決まるため、string想定のパラメーターにstring[]が入ってくる可能性がありません。例えば次のようにファイル名とパラメーターの型が対応します。

File Path Type
app/shop/[slug]/page.js Promise<{ slug: string }>
app/shop/[category]/[item]/page.js Promise<{ category: string, item: string }>
app/shop/[...slug]/page.js Promise<{ slug: string[] }>

とは言え、paramsstring型であること以上にくわしいフォーマットを期待する場合があるでしょう。自然数に変換可能な文字列を期待したり、リテラル型を期待したりなどです。その場合はやはりランタイムでバリデーションを行うべきです。

余談ですが、僕がNext.jsで作るときは次のようなnext.d.tsファイルを作ることで、ページコンポーネントの型定義を少し楽にしています。

next.d.ts
import "next";

declare module "next" {
  export type NextSegmentPage<
    Props extends {
      params?: Record<string, string | string[]>;
    } = object,
  > = React.FC<{
    params: Promise<Props["params"]>;
    searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
  }>;
}

paramssearchParamsが定義済みのReact.FCNextSegmentPageとして定義します。それをあたかもnextモジュールからexportされているように型定義しています。

使うときは次のようにparamsの型だけを指定します。(本当はファイル名から勝手に指定されてほしいが無理なので…)

import { NextSegmentPage } from "next";

const Page: NextSegmentPage<{ params: { slug: string } }> = async ({
  params,
  searchParams,
}) => {
  const slug: string = (await params).slug;
  const queryString: string | string[] | undefined = (await searchParams).q;
};

export default Page;

これでわざわざページコンポーネントを書くときに

const Page: React.FC<{
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}>;

と長々と書かなくても済むようになります。Next.js 15になってPromiseにもなったので、より型定義を楽にする効果があります。

valibotでバリデーションする

searchParamsの型をごまかすことが危険なことはわかりました。型をごまかすのではなくランタイムでバリデーションをしましょう。

valibotを使ってくださいと言ってしまえば終わりなのですが、僕がよくやっている書き方を紹介します。

valibotは宣言的にバリデーションを書くことができるライブラリです。どんな値であるべきかを宣言するように書けるので、読みやすいバリデーションをサクッと実装できます。

zodでも他のバリデーションライブラリでもいいのですが、どれも未使用なら圧倒的にvalibotをおすすめします。バンドルサイズが小さくできるからです。searchParamsの検証はサーバーサイドなのでバンドルサイズは関係ないですが、どうせそのうちクライアントサイドでもバリデーションすることになるのでね。書き心地もzodと比べて劣りません。快適です。

npm install valibot

基本方針

僕のsearchParamsバリデーションの方針は次の通りです。

  • stringを期待するパラメーターがstring[]になっていたら失敗とする
  • 変換する必要があるパラメーターはvalibotのv.transform()で変換まで行う
  • パースにはv.safeParse()/v.safeParseAsync()ではなくv.parse()/v.parseAsync()を使う

v.parse()/v.safeParse()はバリデーションを実行するメソッドで、違いはエラーをthrowするかResult型を返すかです。

stringを期待するパラメーターがstring[]になっていたら失敗とする

前述の通り、searchParamsは同じキーを複数指定することでstring[]として渡すことができます。ただ、多くのsearchParamsは複数指定されることは期待しません。例えばURLにsortを2つ含む場合、それはサイトの訪問者がURLバーに手入力した可能性が高く、無視しても問題ないと考えます。

const QueryStringSchema = v.object({
  q: v.optional(v.string()),
});

// qが配列なので、エラーをthrowする
v.parse(QueryStringSchema, { q: ["foo", "bar"] });

上記コードはqstringundefinedであることを期待し、string[]の場合は検証失敗とします。もし単一の値を期待するパラメーターに複数指定されたとき、検証失敗ではなく先頭の値を採用する方針としたい場合は次のようにv.union()v.transform()を使ってスキーマを組み立てることができます。

const QueryStringSchema = v.object({
  q: v.optional(
    v.pipe(
      v.union([v.string(), v.array(v.string())]),
      v.transform((value) => (Array.isArray(value) ? value[0] : value)),
    ),
  ),
});

// qが配列なので、先頭の値を採用する
const validated = v.parse(QueryStringSchema, { q: ["foo", "bar"] });

あまり多くはないかも知れませんが、逆にstring[]を期待するパラメーターはstringstring[]に変換するようなv.transform()を噛ませておくと、プログラム上は常にstring[]として扱えて便利です。

const QueryStringSchema = v.object({
  q: v.optional(
    v.pipe(
      v.union([v.string(), v.array(v.string())]),
      v.transform((value) => (Array.isArray(value) ? value : [value])),
    ),
  ),
});

// validated.q は string[] 型
const validated = v.parse(QueryStringSchema, { q: ["foo", "bar"] });

変換する必要があるパラメーターはvalibotのtransform()で変換まで行う

検索結果ページなどを実装する際はpageに自然数フォーマットのstringを期待するでしょう。その時、v.parse()したらすでにnumberになっていると嬉しいです。v.transform()で型も合わせておくと、プログラム上で別途変換しなくて良いです。

const PageSchema = v.object({
  page: v.pipe(
    v.string(),
    v.transform((v) => Number(v)),
    v.integer(),
    v.minValue(1),
  ),
});

// validated.page は number 型
const validated = v.parse(PageSchema, { page: "1" });

別の例として、圧縮した文字列をsearchParamsで扱っていることもあるかもしれません。searchParamsを圧縮する手段については以前記事を書きましたので参考になれば幸いです。

https://zenn.dev/chot/articles/lz-string-vs-compression-stream

この記事にあるcompressToEncodedURIComponent()で圧縮された文字列が、searchParamsに渡されてきたことを考えます。その文字列を解凍するにはdecompressFromEncodedURIComponent()を使いますが、これもスキーマの中で変換処理を噛ませてしまいます。

const CompressedCodeSchema = v.objectAsync({
  code: v.optionalAsync(
    v.pipeAsync(
      v.string(),
      v.transformAsync(async (value) => {
        try {
          return await decompressFromEncodedURIComponent(value);
        } catch {
          return undefined;
        }
      }),
    ),
  ),
});

const codeCompressed =
  await compressToEncodedURIComponent('const foo = "bar";');

const validated = await v.parseAsync(CompressedCodeSchema, {
  code: codeCompressed,
});

compressToEncodedURIComponent()は非同期処理なので、valibotのメソッドも非同期版を使用していることに注意してください。非同期処理を含むスキーマも同期版のメソッド名にAsyncを付けるだけで非常にわかりやすいのがvalibotの嬉しいポイントです。

パースにはv.safeParse()/v.safeParseAsync()ではなくv.parse()/v.parseAsync()を使う

v.safeParse()はその結果がいわゆるResult型となります。それはそれで嬉しいケースもありますが、searchParamsの検証でいちいち成功可否の分岐を書くのは手間です。バリデーションに失敗してもさっさとデフォルト値で埋めて、処理を続行したい。

対してv.parse()は一発で検証済みの値を取得できますが、バリデーション違反があるとエラーをthrowします。searchParamsを検証するだけでエラーを投げられるのは面倒ですが、必ず成功するスキーマを組み立てればtry-catchする必要もなくなります。必ず成功するスキーマを組み立てるには、v.fallback()を使用します。

import { NextSegmentPage } from "next";
import * as v from "valibot";

const SearchParamsSchema = v.object({
  q: v.fallback(v.string(), ""),
  page: v.fallback(
    v.pipe(
      v.string(),
      v.transform((v) => Number(v)),
      v.integer(),
      v.minValue(1),
    ),
    1,
  ),
  sort: v.fallback(v.picklist(["asc", "desc"]), "asc"),
});

const Page: NextSegmentPage<{
  params: {
    slug: string;
  };
}> = async ({ searchParams }) => {
  const validatedSearchParmas = v.parse(SearchParamsSchema, await searchParams);

  validatedSearchParmas.q; // string
  validatedSearchParmas.page; // number
  validatedSearchParmas.sort; // "asc" | "desc"

  // ...
};

export default Page;

上記コードはsearchParamsの各キーにv.fallback()を使用しています。(ここまでの記事のまとめコードにもなっています)

上記コードはv.parse()によってエラーがthrowされることはありません。すべてのキーがv.fallback()を使用しており、バリデーション違反があってもデフォルト値で埋められるためです。また、各キーそれぞれでv.fallback()を使用しているので、バリデーションに成功した値はそれが使われ、失敗した値だけがフォールバック値で埋められます。

例えばsort"asc""desc"を期待しますが、それ以外の値が指定された場合は無視して"asc"にフォールバックします。

また、pagenumberに変換した後さらに自然数かどうかのチェックもしていますが、それに失敗した場合も1にフォールバックします。小数やゼロ以下の数に変換できてしまうのはサイト訪問者が手入力している可能性が高いため、無視しても問題ないという判断です。

これで、searchParamsをランタイムにバリデーションできるようになりました。エラーをthrowすることもないし、失敗したらフォールバックさせることもできるし、型安全でもあります。valibotは本当に便利ですね。

まとめ

Next.jsのsearchParamsの型は実際にstring | string[] | undefinedになり得ます。asアサーションなどで型をごまかすのは避けてランタイムでバリデーションをしてください。

valibotを使うことで、バリデーションのスキーマを宣言的に記述できます。検証中にv.transform()で変換処理も挟むことができます。また、v.fallback()を適宜使用することで、v.parse()を使ってもエラーはthrowせずに一発で検証済みの値を取得できて便利です。

valibotでNext.jsの安全性を高めましょう。もちろんsearchParams以外も、外界からの値のバリデーションにも使えます。

それでは良いvalibot/Next.jsライフを!

GitHubで編集を提案
chot Inc. tech blog

Discussion