Guide/guides

バリデーション

リクエストの入力をスキーマで検証し、型の付いたパース済みの値を受け取ります。入力が不正なときは 422 のレスポンスを返して終わります。Inertia のリクエストなら、フィールドごとのエラーメッセージを付けてフォームへリダイレクトします。

バリデーション

リクエストの入力をスキーマで検証し、型の付いたパース済みの値を受け取ります。入力が不正なときは 422 のレスポンスを返して終わります。Inertia のリクエストなら、フィールドごとのエラーメッセージを付けてフォームへリダイレクトします。

スキーマは zod 4 で書きます(import { z } from 'zod')。ほかのライブラリを使う場合はスキーマインターフェースを参照してください。

検証する場所の選び方

場所 使う場面 値の受け取り方
コントローラーのヘルパー 通常はこれ。コントローラーのアクションの中で検証する this.validateBody() などの戻り値
ルートコントラクト ルートの型を codegen、OpenAPI、エージェントのツールにも渡したい this.validated('route.name')
ミドルウェア ハンドラーがインラインの関数のときや、リクエストによってスキーマを変えたいとき getValidatedData(ctx)
validate() / validateSafe() 検証するデータがリクエストから来ない(ジョブ、コンソールコマンド、スクリプト) 戻り値

Laravel から移行したコード向けに、クラスで書く FormRequest の層も残っています。非推奨なので、新しく書く検証には上の 4 つのどれかを使ってください。

クイックスタート

アクションの先頭でヘルパーを呼びます。戻り値はパース済みの値なので、下の data には { title: string; content: string } の型が付きます。

import { Controller, paginate } from '@guren/core'
import { z } from 'zod'
import { Post } from '@/app/Models/Post'
import { PostResource } from '@/app/Http/Resources/PostResource'
import { pages } from '@/.guren/pages.gen'

const StorePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
})

const PageQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
})

export default class PostsController extends Controller {
  async index() {
    const { page } = this.validateQuery(PageQuerySchema)
    const result = await Post.paginate({ page, perPage: 10 })
    const paginator = paginate(result, { path: this.request.path ?? '/posts' })

    return this.inertia(pages.posts.Index, {
      data: result.data.map((post) => new PostResource(post).toJSON()),
      pagination: {
        meta: paginator.meta(),
        links: paginator.links(),
      },
    })
  }

  async store() {
    const data = await this.validateBody(StorePostSchema)
    const post = await Post.create(data)
    return this.created({ post: new PostResource(post).toJSON() })
  }
}

ボディが StorePostSchema を満たさないと、validateBody() が ValidationException を投げてアクションはそこで止まります。この例外を自分で catch する必要はありません。フレームワークがフォームバリデーションエラーで説明するレスポンスに変えて返します。

コントローラーバリデーションヘルパー

ヘルパーはそれぞれ、リクエストの決まった部分を読みます。

ヘルパー 入力元 非同期 失敗したとき
this.validateBody(schema) JSON またはフォームのボディ はい ValidationException(422)を投げる
this.validateQuery(schema) クエリ文字列(?page=1&sort=desc) いいえ ValidationException(422)を投げる
this.validateParams(schema) ルートパラメータ(:id、:slug) いいえ ValidationException(422)を投げる
this.validateBodySafe(schema) JSON またはフォームのボディ はい { success: false, errors } を返す
this.validateQuerySafe(schema) クエリ文字列 いいえ { success: false, errors } を返す
this.validateParamsSafe(schema) ルートパラメータ いいえ { success: false, errors } を返す

エラーの扱いをアクションの中で決めたいときは、Safe の付いた版を使います(Inertia での表示の例を参照)。成功したときの戻り値は { success: true, data } です。

ルートパラメータは文字列で届くので、スキーマの側で型を変換します。

const PostIdParamSchema = z.object({
  id: z.coerce.number().int().positive(),
})

export default class PostsController extends Controller {
  async show() {
    const { id } = this.validateParams(PostIdParamSchema)
    const post = await Post.findOrFail(id)
    return this.json(post)
  }
}

配列形式のクエリパラメータ

同じクエリキーが繰り返されると、スキーマには配列として渡ります。たとえば ?tag=a&tag=b は { tag: ['a', 'b'] } になります。一方、1 回しか出てこないキーはただの文字列のままです。1 回のことも複数回のこともあるパラメータには、union を使ってください。

const FilterQuerySchema = z.object({
  // ?tag=a&tag=b -> ['a', 'b'] / ?tag=a -> 'a'
  tag: z.union([z.string(), z.array(z.string())]).optional()
    .transform((value) => (typeof value === 'string' ? [value] : value ?? [])),
})

この挙動は、this.validateQuery() にも、ルートコントラクトで指定する query: スキーマにも当てはまります。

ルートコントラクトによる検証

ルートに params、query、body のスキーマを宣言しておくと、ハンドラーを呼ぶ前に入力が検証されます。ハンドラーがコントローラーのアクションでも同じです。アクションの中でスキーマを書き直す必要はなく、パース済みの値を this.validated() で読み出します。

router.post('/posts', { name: 'posts.store', body: StorePostSchema }, [PostsController, 'store'])

export default class PostsController extends Controller {
  async store() {
    const { body } = this.validated('posts.store')
    const post = await Post.create(body)
    return this.created({ post })
  }
}

コントローラーのアクションで検証に失敗すると、ヘルパーと同じ ValidationException(422)が投げられます。そのため、Inertia のフォームでは手を加えずにエラーを表示できます。ハンドラーがインラインの関数のルートは、ミドルウェアと同じ形の 422 をその場で返します(フォームバリデーションエラーを参照)。詳しくは検証済み入力の読み取りを参照してください。

ミドルウェアバリデーション

validateRequest(schema)

validateRequest() は、ハンドラーより前にリクエストボディを検証するミドルウェアを作ります。ハンドラーでは、パース済みのボディを getValidatedData() で取り出します。

import { Router, getValidatedData, validateRequest } from '@guren/core'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
})

const router = new Router()

router.post('/posts', async (ctx) => {
  const data = getValidatedData<z.infer<typeof CreatePostSchema>>(ctx)!
  return ctx.json({ post: await Post.create(data) })
}, validateRequest(CreatePostSchema))

ミドルウェアがそのルートで動いていなければ getValidatedData() は undefined を返すので、例では ! を付けています。

検証に失敗すると、ミドルウェアがその場で 422 を返します。中身はトップレベルのフィールドごとに最初のメッセージ 1 つです。

{
  "errors": {
    "title": "Too small: expected string to have >=1 characters"
  }
}

validateRequestWith(schemaFactory)

リクエストに応じてスキーマを組み立てたいときに使います。スキーマを返す関数は同期的に呼ばれるので、その中でログイン中のユーザーを await することはできません。必要な値は、resolveIsAdmin のように前段のミドルウェアで解決しておいてください。

import { AUTH_CONTEXT_KEY, Router, defineMiddleware, validateRequestWith } from '@guren/core'
import type { AuthContext } from '@guren/core'

const resolveIsAdmin = defineMiddleware(async (ctx, next) => {
  const auth = ctx.get(AUTH_CONTEXT_KEY) as AuthContext | undefined
  const user = await auth?.user<{ role: string }>()
  ctx.set('isAdmin', user?.role === 'admin')
  await next()
})

const router = new Router()

router.put('/users/:id', [UserController, 'update'], resolveIsAdmin, validateRequestWith((ctx) => {
  const isAdmin = ctx.get('isAdmin') === true

  return z.object({
    name: z.string().min(1),
    email: z.email(),
    // 管理者のみロール変更可能
    role: isAdmin ? z.enum(['user', 'admin']) : z.never().optional(),
  })
}))

アクションでは、パース済みのボディを getValidatedData(this.ctx) で取り出します。

カスタムエラーハンドリング

どちらのミドルウェアも onError オプションを受け取り、422 のレスポンスを差し替えられます。引数には、フィールド名からメッセージへの同じ対応表が渡ります。

validateRequest(schema, {
  onError: (ctx, errors) => {
    return ctx.json({
      message: 'バリデーションに失敗しました',
      errors: Object.entries(errors).map(([field, message]) => ({ field, message })),
    }, 422)
  },
})

ステータスだけを変えたいなら status を、どのフィールドにも結び付かないエラーのメッセージを変えたいなら fallbackMessage を指定します。

手動バリデーション

リクエストから来ないデータは、validate() か validateSafe() で検証します。

import { validate, validateSafe } from '@guren/core'

// パース済みのデータを返す。失敗するとスキーマ自身のエラー(zod なら ZodError)を投げる
const data = validate(schema, input)

// 例外は投げない
const result = validateSafe(schema, input)
if (result.success) {
  console.log(result.data)
} else {
  console.log(result.errors) // { field: '最初のメッセージ' }
}

フォームバリデーションエラー

エラーの形は、検証した場所によって次のように異なります。「ネストのキー」の列は、address.postalCode で失敗したときのキーです。

発生元 形 キーあたりのメッセージ ネストのキー
ヘルパーとコントラクトの body、JSON リクエスト { message, errors: { [key]: string[] } }、422 すべて address.postalCode
コントラクトの params / query、JSON リクエスト { message, errors: { [key]: string[] } }、422 1 つ address
すべての ValidationException、Inertia リクエスト ページの errors prop:{ [key]: string } 1 つ 投げたときのまま
this.validateBodySafe() など { success: false, errors: { [key]: string } } 1 つ address.postalCode
validateRequest()、validateSafe()、インラインのハンドラーのコントラクト { errors: { [key]: string } } 1 つ address

キーがトップレベルのフィールド名になる場所では、どのフィールドにも結び付かないエラーに message というキーが付きます。

JSON リクエスト

JSON や API のリクエストで ValidationException が投げられると、次の 422 を返します。

{
  "message": "The given data was invalid.",
  "errors": {
    "email": ["Invalid email address"],
    "password": ["Too small: expected string to have >=8 characters"]
  }
}

Inertia リクエストの自動ハンドリング

Inertia のリクエスト(X-Inertia ヘッダー付き)には JSON を返さず、次の順で処理します。

  1. エラーを次のリクエストまでの一度だけ保持します。
  2. 直前のページへ 303 でリダイレクトします。
  3. そのページを読み込むと、エラーが共有 props の errors に入ります。メッセージはフィールドごとに 1 つです。
function Login({ errors }: { errors?: Record<string, string> }) {
  return (
    <form>
      <input name="email" />
      {errors?.email && <span className="error">{errors.email}</span>}
    </form>
  )
}

validateBody、validateQuery、validateParams、ルートコントラクトの失敗にも、自分のコードで投げた ValidationException.withMessages(...) にも、同じ処理が適用されます。

細かい点は次のとおりです。

  • アプリにセッションがあればエラーをセッションに保持し(createApp({ auth }) でマウントされます)、なければ有効期限の短い Cookie に保持します。そのため、作ったばかりのアプリでもエラーが表示されます。
  • リダイレクト先は Referer ヘッダーで、ヘッダーがなければ / です。
  • この処理を差し替えるには、サービスプロバイダーの boot() で独自のレンダラーを登録します。this.container.make<ExceptionHandler>('exception.handler').render(ValidationException, (error, ctx) => …) のように書くと、組み込みのものより先に呼ばれます。

Inertia での表示

リダイレクトせずに同じレスポンスでエラーを表示したいときは、Safe の付いたヘルパーを使い、戻り値の errors をページに渡します。ページの errors prop は ValidationErrors<T> で宣言します。codegen がページの Props を読み取るので、this.inertia() に渡す値もその型で検査されます。

import { pages } from '@/.guren/pages.gen'

async store() {
  const result = await this.validateBodySafe(schema)
  if (!result.success) {
    return this.inertia(pages.users.Create, {
      errors: result.errors,
    })
  }

  await User.create(result.data)
  return this.redirect('/users')
}
import type { ValidationErrors } from '@guren/core'

interface Props {
  errors?: ValidationErrors<'email' | 'password'>
}

export default function CreateUser({ errors }: Props) {
  return (
    <form>
      <input name="email" />
      {errors?.email && <span className="error">{errors.email}</span>}

      <input name="password" type="password" />
      {errors?.password && <span className="error">{errors.password}</span>}
    </form>
  )
}

一般的なパターン

ネストされたオブジェクト

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  postalCode: z.string().regex(/^\d{3}-\d{4}$/),
})

const userSchema = z.object({
  name: z.string(),
  address: addressSchema,
})

配列

const schema = z.object({
  tags: z.array(z.string()).min(1).max(10),
  items: z.array(z.object({
    productId: z.number(),
    quantity: z.number().positive(),
  })),
})

デフォルト値付きオプショナル

const schema = z.object({
  page: z.coerce.number().positive().default(1),
  perPage: z.coerce.number().positive().max(100).default(20),
  sortBy: z.enum(['created', 'updated', 'name']).default('created'),
})

変換

const schema = z.object({
  email: z.email().toLowerCase(),
  tags: z.string().transform(s => s.split(',').map(t => t.trim())),
  date: z.string().transform(s => new Date(s)),
})

絞り込み

const schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: 'パスワードが一致しません',
  path: ['confirmPassword'],
})

スキーマインターフェース

Guren は Zod を import せずにスキーマ自身のメソッドを呼ぶので、次の形を持つオブジェクトならどれでも使えます。validate() は parse() を、それ以外はすべて safeParse() を呼びます。

interface ValidationSchema<T> {
  parse(data: unknown): T
  safeParse(data: unknown):
    | { success: true; data: T }
    | { success: false; error: { issues: Array<{ path: PropertyKey[]; message: string }> } }
}

エラーのキーは issues[].path から作られます。ほかのライブラリのスキーマを使うときは、この形を返す小さなアダプターを書いてください。

スキーマの構造を読み取るツール(型付きのルートマニフェストを書き出す guren codegen、OpenAPI 生成、guren context)は zod 4 にだけ対応しています。zod v3 の API(zod@3 パッケージや zod/v3 サブパス)で書いたスキーマは、警告を出して読み飛ばします。

コンパイル済みパース(zod 4.5+)

zod 4.5 では、スキーマを高速な生成コードにコンパイルできます。create-guren-app で作ったアプリでは最初から有効で、src/app.ts の先頭に次の import が入っています。

import 'zod/compile'

この import より後に組み立てたスキーマは、コンパイル済みの経路でパースされます。Guren のバリデーションヘルパー、ルートコントラクト(params、query、body、output)、バリデーションミドルウェアは、どれもスキーマ自身の parse/safeParse を呼んでいるので、コードを書き換えなくても速くなります。

既存のアプリで使うには、zod の依存を ^4.5.0 に上げたうえで、エントリモジュールの最初の行にこの import を追加してください。スキーマを定義するどのモジュールよりも前に読み込まれる必要があります。

Bun で計測したところ、100 件のリスト出力の検証は約 19µs から 1.2µs に縮み、5 フィールドのボディのパースは約 4 倍速くなりました。エンドツーエンドでは、検証付きで 100 件のリストを返す API エンドポイントのスループットが約 10% 上がり、レイテンシの中央値も下がりました。どれだけ効くかは、ルートの処理時間のうち検証が占める割合で変わります。

注意点が 3 つあります。

  • import の順序が効きます。 import より前に組み立てたスキーマは、通常のパーサーのまま動きます。必ずエントリモジュールの先頭に置いてください。
  • 制約のあるランタイムでも動きます。 生成コードを禁止しているランタイム(厳格な CSP など)では、z.config({ jitless: true }) を呼べばコンパイルを飛ばし、すべてそのまま動きます。対応していないスキーマ機能も通常のパーサーにフォールバックするだけで、コンパイルが例外を投げることはありません。
  • .refine() や .transform() には副作用を持たせないでください。 不正な入力に対しては、.refine() や .transform() のコールバックが 2 回実行されることがあります。高速経路のあとに、完全なエラーを組み立てるためのフォールバックが走るからです。検証結果は変わりませんが、コールバック内の副作用は 2 回起きます。

zod 以外のスキーマには影響しません。これまでどおり、それぞれの parse/safeParse で動きます。