バリデーション
リクエストの入力をスキーマで検証し、型の付いたパース済みの値を受け取ります。入力が不正なときは 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 を返さず、次の順で処理します。
- エラーを次のリクエストまでの一度だけ保持します。
- 直前のページへ
303でリダイレクトします。 - そのページを読み込むと、エラーが共有 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 で動きます。