Zod z.infer でネスト・配列の型推論が壊れる原因|strict・passthrough・transform との組み合わせ落とし穴
Zod TypeScript バリデーション 型推論 API
結論
.strict() はネストに伝播しません。各層に個別に指定が必要です。
strict() がネストに伝播しない仕様
Zod の公式ドキュメントに明記されているとおり、.strict() はそのオブジェクト自身にのみ適用され、ネストしたオブジェクトには伝播しません。
import { z } from 'zod';
const ItemSchema = z.object({
id: z.string(),
// .strict() なし
});
const ListSchema = z.object({
items: z.array(ItemSchema),
}).strict(); // リスト自体には strict
// ✅ リスト直下の未知キーはエラーになる
ListSchema.safeParse({ items: [], unknown: 'x' });
// → { success: false, error: ... "Unrecognized key(s): 'unknown'" }
// ❌ items の要素の未知キーはスルーされる(.strip() として動作)
ListSchema.safeParse({ items: [{ id: '1', unknownField: 'x' }] });
// → { success: true, data: { items: [{ id: '1' }] } } (unknownField は削除される)
ネスト先にも strict を適用するには、各 z.object() に個別に付ける必要があります。
// ✅ ネスト先にも strict を付ける
const ItemSchema = z.object({
id: z.string(),
}).strict(); // ← 必要
const ListSchema = z.object({
items: z.array(ItemSchema),
}).strict();
// これで items 内の unknownField もエラーになる
ListSchema.safeParse({ items: [{ id: '1', unknownField: 'x' }] });
// → { success: false, error: ... }
strip・strict・passthrough の3モードの比較
| モード | 未知キーの扱い | デフォルト |
|---|---|---|
.strip() | 削除して通過させる | ✅ デフォルト |
.strict() | エラーを返す | — |
.passthrough() | そのまま通過させる | — |
外部公開 API → .strict()(未知フィールドを拒否してセキュリティを高める)
内部マイクロサービス間 → .strip() または .passthrough()(前方互換のため未知キーを許容する)
transform による z.infer の型変化
.transform() を使うとスキーマの入力型と出力型が変わり、z.infer は出力型(変換後)を返します。
const DateStringSchema = z.object({
createdAt: z.string().datetime().transform((s) => new Date(s)),
});
// z.infer は変換後の型を返す
type Output = z.infer<typeof DateStringSchema>;
// → { createdAt: Date } ← string ではなく Date
// HTTPリクエストを受け取る際の入力型が必要なら z.input を使う
type Input = z.input<typeof DateStringSchema>;
// → { createdAt: string } ← 変換前
Express・Hono・Fastify のミドルウェアでスキーマを検証する場合、req.body は変換前(z.input)なので、型注釈は z.input を使う方が正確です。
// ミドルウェアの型注釈例
type RequestBody = z.input<typeof DateStringSchema>; // { createdAt: string }
type ParsedBody = z.infer<typeof DateStringSchema>; // { createdAt: Date }
ネスト配列で型推論が壊れるパターン
パターン1:passthrough と transform の組み合わせ
.passthrough() の後に .transform() を繋ぐと、TypeScript が内部 Zod クラスを型として推論してしまい z.infer が機能しなくなる場合があります(Zod の既知の型推論バグ)。
// ❌ passthrough + transform で z.infer が壊れることがある
const schema = z.object({ a: z.string() })
.passthrough()
.transform((data) => ({ ...data, computed: true }));
type T = z.infer<typeof schema>; // ZodObject<...> になってしまう場合がある
// ✅ transform を先に適用してから passthrough は使わない、または z.any() で型を明示
const schema = z.object({ a: z.string() })
.transform((data) => ({ ...data, computed: true as const }));
type T = z.infer<typeof schema>; // { a: string; computed: true }
パターン2:ジェネリック関数でスキーマをラップすると型が失われる
// ❌ ジェネリック制約が広すぎて型が失われる
function withMeta<T extends z.ZodObject<any>>(schema: T) {
return schema.extend({ _meta: z.string().optional() });
}
const ResultSchema = withMeta(z.object({ id: z.string() }));
type Result = z.infer<typeof ResultSchema>; // { id: string; _meta?: string } になるはずが...
// ✅ 型引数を z.ZodRawShape に変更して推論を保持
function withMeta<T extends z.ZodRawShape>(shape: T) {
return z.object({ ...shape, _meta: z.string().optional() });
}
const ResultSchema = withMeta({ id: z.string() });
type Result = z.infer<typeof ResultSchema>; // { id: string; _meta?: string } ✅
エラーパスを特定する:error.format()
ネスト配列のどのインデックスのどのキーでエラーが起きているかを特定するには error.format() が便利です。
const result = ListSchema.safeParse({
items: [
{ id: '1' },
{ id: undefined, unknownField: 'x' }, // index 1 でエラー
],
});
if (!result.success) {
console.log(result.error.format());
// {
// _errors: [],
// items: {
// _errors: [],
// '1': {
// id: { _errors: ['Required'] },
// _errors: [],
// }
// }
// }
}
error.issues の各 issue.path には ['items', 1, 'id'] のようにネスト階層がそのまま配列で入っています。
Failure Boundary:z.any() と z.unknown() の意図しない使用
z.any() を使うとその位置の型チェックが完全に無効化され、z.infer でも any になります。型安全性が必要な場合は z.unknown() を使ってください。
// ❌ z.any() は型チェックを完全に無効化
const schema = z.object({ data: z.any() });
type T = z.infer<typeof schema>; // { data: any }
// ✅ z.unknown() は値の取得を型安全に強制
const schema = z.object({ data: z.unknown() });
type T = z.infer<typeof schema>; // { data: unknown }
// unknown を使う側では型ガードが強制される