📥

zodを利用したquery stringのparse

2023/01/17に公開2

検索結果画面等で検索条件をquery stringで渡す際、どうやってparseするのがbetterか毎回モヤモヤしていた。
最近、zodでいい感じにparseできているのでメモがてら共有してみる。

概要

schemaを作ってsafeParseして、trueならその値を、falseならdefault値を返す。
たとえばidとかの自然数の場合、

export const checkIsPositiveInt = (target: unknown): target is number =>
  z.number().int().positive().safeParse(target).success;

export const parseToPositiveInt = <T>(target: unknown, defailtValue: T) => {
  return checkIsPositiveInt(Number(target)) ? Number(target) : defailtValue;
};

を用意しておいて、以下のように使う

const parsedPage = parseToPositiveInt(query?.page, 1)

水平展開

例えば自然数配列とかbool値は今のところ以下みたいな感じ。

export const checkIsIntArray = (target: unknown): target is number[] =>
  z.array(z.number().int()).safeParse(target).success;

export const parseToIntArray = <T>(target: unknown, defailtValue: T) => {
  return target != null &&
    Array.isArray(target) &&
    checkIsIntArray(target.map((v) => Number(v)))
    ? target.map((v) => Number(v))
    : defailtValue;
};

export const checkIsBoolean = (target: unknown): target is boolean =>
  z.boolean().safeParse(target).success;

export const parseToBoolean = <T>(target: unknown, defailtValue: T) => {
  const convertedTarget =
    target === 'true' ? true : target === 'false' ? false : target;
  return checkIsBoolean(convertedTarget) ? convertedTarget : defailtValue;
};

これらを使って検索条件などを以下みたいな感じでparseする。

export const parseQueryString = (query: ParsedUrlQuery): PageQuery => {
  const {
    page,
    perPage,
    categoryIds,
    isRecommended,
  } = unflatten<ParsedUrlQuery, { [key in keyof PageQuery]?: unknown }>(query);

  return {
    page: parseToPositiveInt(page, 1),
    perPage: parseToPositiveInt(perPage, PER_PAGE_OPTIONS[0].value),
    categoryIds: parseToIntArray(categoryIds, []),
    isRecommended: parseToBoolean(isRecommended, false),
  };
};

zod v3.20で追加されたcoerceを使えばより簡潔に書けるのかな?
いい感じのparse方法があればコメントで教えていただけると幸いです。

Discussion

nap5nap5

軽く調べただけでしたが、以下のワークアラウンドを見つけました。

https://medium.com/@Seb_L/typing-nextjs-router-query-params-with-zod-coerce-e240ab430c2b

coerce でできるぽそうですね。

簡単ですが、以上です。

nap5nap5

Bool値のNice化はtransformでハンドリングしてみました。

src/features/search/domains/searchQueryParams.ts

import { z } from 'zod'

export const SearchQueryParamsSchema = z
  .object({
    term: z.coerce.string().optional(),
    page: z.coerce.number().optional(),
    isHuman: z.custom<Boolean | String>().optional(),
    isRecommended: z.custom<Boolean | String>().optional(),
    // isHuman: z.coerce.boolean().optional(),
    // isRecommended: z.coerce.boolean().optional(),
  })
  .transform((value, ctx) => {
    return {
      ...value,
      isHuman: value.isHuman === 'true' ? true : false,
      isRecommended: value.isRecommended === 'true' ? true : false,
    }
  })

src/features/search/hooks/useTypedRouter.ts

import { NextRouter, useRouter } from 'next/router'

import { Err, Ok, Result } from 'neverthrow'
import { z } from 'zod'
// https://medium.com/@Seb_L/typing-nextjs-router-query-params-with-zod-coerce-e240ab430c2b
export const useTypedRouter = <T extends z.Schema>(
  schema: T
): Result<NextRouter, Error> => {
  const { query, ...router } = useRouter()
  const parsed = schema.safeParse(query)
  if (!parsed.success) {
    return new Err(
      new Error('Invalid query parameter...', {
        cause: parsed.error,
      })
    )
  }

  return new Ok({
    query: schema.parse(query) as z.infer<typeof schema>,
    ...router,
  })
}

src/pages/search/index.tsx

const SearchPage: NextPage = () => {
  const { query } = useRouter()
  console.log(`[SearchPage]raw`, query)
  const result = useTypedRouter(SearchQueryParamsSchema)
  if (result.isErr()) {
    return (
      <SearchLayout>
        <p>Something went wrong...</p>
      </SearchLayout>
    )
  }
  const router = result.value
  console.log(`[SearchPage]parsed`, router.query)
  return (
    <SearchLayout>
      <ShowMe data={router.query} />
    </SearchLayout>
  )
}

/searchページがデモになります。

https://codesandbox.io/p/sandbox/stupefied-https-o802rc?file=%2Fsrc%2Fpages%2Fsearch%2Findex.tsx

簡単ですが、以上です。