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 を使う側では型ガードが強制される