5. リファインメント・パイプライン・双方向Codec

実世界のバリデーションでは、単なる構造や型のチェック以上の要件が頻繁に発生します。パスワードと確認用フィールドの一致検証、文字列のインプレース正規化、フォーム入力値からドメインモデルへの多段階パイプライン変換、そして通信フォーマット(JSON文字列、ISO 8601タイムスタンプ、URLSearchParams)とJavaScriptの実行時オブジェクト(Date、Map、型付きドメインモデル)の間の双方向シリアライズ/デシリアライズなどです。

Zod v4は、カスタム検証フック(.refine()、.superRefine()、.check())、型を保持するインプレース変換(.overwrite())、単方向パイプライン(.transform()、.pipe())、そして**ファーストクラスの双方向Codec(z.codec()、z.decode()、z.encode())**を統合しています。

05_refinements_and_codecs_1
Zod v4 の双方向 z.codec() デコード・エンコード基盤と単方向パイプラインの比較
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel, Table
from drawlib.styles import Styles

setup(width=144, height=64)

left_panel = Panel(
    "z.codec(In, Out, {...})",
    "双方向 Codec 変換",
    style=Styles.PrimaryNeutral,
    header_style=Styles.PrimaryFlat,
    text_style=Styles.WhiteBold.patch(text_size=11.0),
    description_style=Styles.White.patch(text_size=10.0),
    icon=phosphor.arrows_left_right,
    icon_style=Styles.White,
    header_variant="filled",
    header_height=9.5,
)
left_panel.draw((4.0, 4.0), width=66.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box(
    (4.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)

codec_cards = CardList(
    style=Styles.White,
    text_style=Styles.DarkBold.patch(text_size=10.5),
    description_style=Styles.Dark.patch(text_size=10.0),
    badge_style=Styles.SecondaryNeutral,
    badge_text_style=Styles.DarkBold.patch(text_size=10.0),
    spacing=2.0,
)
codec_cards.add(
    "順方向: z.decode(codec, wire)",
    "In を検証 -> decode() 実行 -> Out を検証",
    badge_text="DEC",
    style=Styles.PrimaryFlat,
    text_style=Styles.WhiteBold.patch(text_size=10.5),
    description_style=Styles.White.patch(text_size=10.0),
    badge_style=Styles.White,
    badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
)
codec_cards.add(
    "逆方向: z.encode(codec, domain)",
    "Out を検証 -> encode() 実行 -> In を検証",
    badge_text="ENC",
)
codec_cards.add(
    "z.stringbool() & z.invertCodec()",
    "環境変数真偽値 Codec と方向反転ヘルパー",
    badge_text="INV",
)
codec_cards.draw(l_xy, width=l_w, height=l_h)

right_panel = Panel(
    "単方向パイプライン",
    "一方向の変換と検証",
    style=Styles.Neutral,
    header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
    text_style=Styles.DarkBold.patch(text_size=11.0),
    description_style=Styles.Dark.patch(text_size=10.0),
    icon=phosphor.arrow_right,
    icon_style=Styles.DarkBold,
    header_variant="filled",
    header_height=9.5,
)
right_panel.draw((74.0, 4.0), width=66.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box(
    (74.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)

pipe_table = Table(
    cell_style=Styles.White,
    text_style=Styles.Dark.patch(text_size=10.0),
    header_cell_style=Styles.PrimaryFlat,
    header_text_style=Styles.WhiteBold.patch(text_size=10.0),
    border_style=Styles.MutedThin,
)
pipe_table.draw(
    xy=r_xy,
    width=r_w,
    height=r_h,
    align="bottom_left",
    data=[
        ["パイプライン API", "方向性と型の効果"],
        [".overwrite(fn)", "型保持変換 (Out == In)"],
        [".transform(fn)", "単方向変換 (encode 不可)"],
        [".pipe(target)", "Output<A> -> Input<B>"],
        ["z.preprocess(fn, B)", "B の前に前処理を実行"],
        [".refine() / .check()", "カスタム検証 (型不変)"],
    ],
)

save()

5.1 カスタム検証:.refine()、.superRefine()、.check()

組み込みのフォーマットチェックだけでは表現できないビジネスルール(複数フィールド間の相関チェック、チェックサム計算、非同期のデータベース重複確認など)のために、Zod v4は3段階のカスタム検証APIを提供しています。

05_refinements_and_codecs_2
Zod v4 におけるカスタム検証APIの3階層とイシュー継続制御フロー
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel
from drawlib.styles import Styles

setup(width=144, height=64)

left_panel = Panel(
    "カスタム検証 API",
    "3段階の抽象度",
    style=Styles.Neutral,
    header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
    text_style=Styles.DarkBold.patch(text_size=11.0),
    description_style=Styles.Dark.patch(text_size=10.0),
    icon=phosphor.check_circle,
    icon_style=Styles.DarkBold,
    header_variant="filled",
    header_height=9.5,
)
left_panel.draw((4.0, 4.0), width=66.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box(
    (4.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)

val_cards = CardList(
    style=Styles.White,
    text_style=Styles.DarkBold.patch(text_size=10.5),
    description_style=Styles.Dark.patch(text_size=10.0),
    badge_style=Styles.SecondaryNeutral,
    badge_text_style=Styles.DarkBold.patch(text_size=10.0),
    spacing=2.0,
)
val_cards.add(
    ".refine(predicate, params)",
    "真偽値関数による条件検証とエラー指定",
    badge_text="L1",
)
val_cards.add(
    ".superRefine((val, ctx) => ...)",
    "ctx.addIssue({...}) による複数イシュー発行",
    badge_text="L2",
)
val_cards.add(
    ".check((ctx) => ...) (v4 新機能)",
    "ctx.issues へ直接追加する低レベルフック",
    badge_text="L3",
    style=Styles.PrimaryFlat,
    text_style=Styles.WhiteBold.patch(text_size=10.5),
    description_style=Styles.White.patch(text_size=10.0),
    badge_style=Styles.White,
    badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
)
val_cards.draw(l_xy, width=l_w, height=l_h)

right_panel = Panel(
    "イシュー実行制御フロー",
    "継続評価とパス指定",
    style=Styles.PrimaryNeutral,
    header_style=Styles.PrimaryFlat,
    text_style=Styles.WhiteBold.patch(text_size=11.0),
    description_style=Styles.White.patch(text_size=10.0),
    icon=phosphor.faders,
    icon_style=Styles.White,
    header_variant="filled",
    header_height=9.5,
)
right_panel.draw((74.0, 4.0), width=66.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box(
    (74.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)

flow_cards = CardList(
    style=Styles.White,
    text_style=Styles.DarkBold.patch(text_size=10.5),
    description_style=Styles.Dark.patch(text_size=10.0),
    badge_style=Styles.PrimaryNeutral,
    badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
    spacing=2.2,
    show_arrows=True,
    arrow_style=Styles.PrimaryBold,
)
flow_cards.add(
    "1. 構造・フォーマット検証",
    "基本型と組み込み制約を評価",
    badge_text="S1",
)
flow_cards.add(
    "2. 継続実行フラグ (continue: true)",
    "非致命的エラー後に後続チェックを継続実行",
    badge_text="S2",
)
flow_cards.add(
    "3. ネストされたパス指定 (path)",
    "path: ['confirmPassword'] で対象キーへ紐付け",
    badge_text="S3",
)
flow_cards.draw(r_xy, width=r_w, height=r_h)

save()

1. .refine() による条件述語チェック

.refine(predicate, params) は同期または async の真偽値関数 (val) => boolean を受け取ります。関数がfalsyな値を返すと、custom 検証イシューが発行されます。また、述語関数がTypeScriptの型ガード (val): val is SubType => ... になっている場合、Zodはスキーマの出力型を自動的に絞り込みます。

import { z } from "zod";

// シンプルな述語関数とエラーメッセージ文字列
const EvenInt = z.int().refine((n) => n % 2 === 0, "偶数である必要があります");

// 複数フィールド間の相関チェック(`path` と `error` の指定)
const PasswordSignupSchema = z
  .object({
    email: z.email(),
    password: z.string().min(12),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: "確認用パスワードが一致しません",
    path: ["confirmPassword"],
  });

2. .superRefine() による複数イシューの発行

1回の検証パスの中で、条件に応じて複数の異なるイシューや異なるフィールドパスへのエラーを発行したい場合は .superRefine((val, ctx) => ...) を使用します。

const TimeRangeSchema = z
  .object({
    startDate: z.iso.date(),
    endDate: z.iso.date(),
    maxSpanDays: z.int().positive(),
  })
  .superRefine((val, ctx) => {
    const startMs = Date.parse(val.startDate);
    const endMs = Date.parse(val.endDate);

    if (endMs < startMs) {
      ctx.addIssue({
        code: "custom",
        message: "終了日は開始日以降である必要があります",
        path: ["endDate"],
      });
      return;
    }

    const spanDays = (endMs - startMs) / (1000 * 60 * 60 * 24);
    if (spanDays > val.maxSpanDays) {
      ctx.addIssue({
        code: "custom",
        message: `期間 (${spanDays}日) が上限 (${val.maxSpanDays}日) を超えています`,
        path: ["endDate"],
      });
    }
  });

3. 低レベルフック .check()(Zod v4 新機能)

Zod v4の内部では、すべてのバリデーション制約が .check() によって実装されています。.check((ctx) => ...) に直接関数を渡すことで、ctx.value を検査し、continue: true(エラー後も後続のチェックを継続実行するフラグ)を含めて ctx.issues 配列へ直接イシューオブジェクトを追加できます。

const StrictSlug = z.string().check((ctx) => {
  if (ctx.value.startsWith("-") || ctx.value.endsWith("-")) {
    ctx.issues.push({
      code: "custom",
      input: ctx.value,
      message: "スラッグの先頭または末尾にハイフンは使用できません",
      continue: true, // 後続のチェックも引き続き実行する
    });
  }
  if (ctx.value.includes("--")) {
    ctx.issues.push({
      code: "custom",
      input: ctx.value,
      message: "連続するハイフンは使用できません",
    });
  }
});

5.2 .overwrite() による型保持ミューテーション(Zod v4 新機能)

Zod v3では、.transform() を呼び出すと常に ZodEffects ラッパー型に変化してしまい、元のスキーマが持っていた .min() や .max() などのメソッドを後からチェーンできなくなる課題がありました。

Zod v4では、型を変えないインプレース変換((x: Output) => Output)のために .overwrite(fn) が導入されました。.overwrite() はラッパー型ではなくチェックの一種として実装されているため:

  1. 元のスキーマクラス(this)をそのまま返すので、.overwrite() の後ろに .min()、.max()、オブジェクト操作メソッドを自在にチェーンできます。
  2. 実際にZod v4の組み込み文字列正規化メソッド(z.string().trim()、.toLowerCase()、.toUpperCase()、.normalize())はすべて内部で .overwrite() によって実装されています。
// `ZodString` の型とメソッドを維持したまま、空白除去と先頭の '@' 除去を実行!
const HandleSchema = z
  .string()
  .trim()
  .overwrite((s) => (s.startsWith("@") ? s.slice(1) : s))
  .toLowerCase()
  .min(3)
  .max(20);

HandleSchema.parse("  @Alice_Dev  "); // => "alice_dev"

5.3 単方向変換とパイプライン(.transform(), .pipe())

実行時の型そのものを別の型へ変換する場合(例:string から number[] やドメインオブジェクトへの変換)は、.transform() と .pipe() を組み合わせてパイプラインを構築します。

// 1. `.transform()` で Output<A> を新しい型へ変換
const CsvTokens = z
  .string()
  .trim()
  .transform((raw) => (raw === "" ? [] : raw.split(",").map((s) => Number(s.trim()))));
// 入力型 (Input): string
// 出力型 (Output): number[]

// 2. `.pipe()` で前のスキーマの出力を次のスキーマの入力へ連結して検証
const CommaSeparatedInts = CsvTokens.pipe(z.array(z.int().positive()).nonempty());

type CommaSeparatedIntsIn = z.input<typeof CommaSeparatedInts>;   // string
type CommaSeparatedIntsOut = z.output<typeof CommaSeparatedInts>; // number[]

CommaSeparatedInts.parse("101, 202, 303"); // => [101, 202, 303]

重要: 単方向の .transform() は順方向(input -> output)の変換関数しか持たないため、.transform() を含むスキーマに対して逆方向の z.encode() を呼び出すと実行時エラーがスローされます。双方向のシリアライズが必要な場合は、必ず次に説明する z.codec() を使用してください。


5.4 双方向Codec(z.codec, z.decode, z.encode)

Zod v4の最も重要な新機能の1つが z.codec(inputSchema, outputSchema, { decode, encode }) です。Codecは入力スキーマ(JSON文字列やISO 8601文字列などの通信・保存用ワイヤフォーマット)と出力スキーマ(Date インスタンスや型付きオブジェクトなどのアプリケーション内部ドメイン型)を対にし、順方向の decode 関数と逆方向の encode 関数を一元管理します。

順方向デコード(Decode)と逆方向エンコード(Encode)

操作 例外スロー関数 Safe結果関数 方向と型シグネチャ
順方向 (Decode) z.decode(schema, input) (または schema.decode(input)) z.safeDecode(schema, input) z.input<S> -> z.output<S>(In を検証 -> decode 実行 -> Out を検証)
逆方向 (Encode) z.encode(schema, output) (または schema.encode(output)) z.safeEncode(schema, output) z.output<S> -> z.input<S>(Out を検証 -> encode 実行 -> In を検証)
未検証入力 (Parse) z.parse(schema, unknownVal) z.safeParse(schema, unknownVal) unknown -> z.output<S>(実行経路は decode と同一、引数型が unknown)

非同期サポート: 非同期の decode / encode 関数も z.decodeAsync()、z.encodeAsync()、z.safeDecodeAsync()、z.safeEncodeAsync() で完全にサポートされています。

例1:ISO 8601 日時文字列 <-> JavaScript Date

import { z } from "zod";

const IsoDateCodec = z.codec(
  z.iso.datetime(), // ワイヤ形式: ISO 8601 文字列 ("2026-11-10T10:30:00Z")
  z.date(),         // ドメイン形式: JavaScript Date インスタンス
  {
    decode: (isoString) => new Date(isoString),
    encode: (dateObj) => dateObj.toISOString(),
  }
);

type WireFormat = z.input<typeof IsoDateCodec>;    // string
type DomainFormat = z.output<typeof IsoDateCodec>; // Date

// 1. ワイヤ文字列 -> Date インスタンスへデコード
const parsedDate = z.decode(IsoDateCodec, "2026-11-10T10:30:00.000Z");
// => Date(2026-11-10T10:30:00.000Z)

// 2. Date インスタンス -> ISO 8601 文字列へエンコード
const serializedIso = z.encode(IsoDateCodec, parsedDate);
// => "2026-11-10T10:30:00.000Z"

例2:汎用 JSON 文字列 <-> 型付きオブジェクト Codec

任意のZodスキーマを受け取る高階関数 jsonCodec(schema) を定義しておくと、JSON文字列のパースとスキーマ検証、およびオブジェクトからのJSON文字列化を双方向に安全に実行できます(不正なJSON構文も payload.issues.push(...) でZodのエラーとして扱えます)。

function jsonCodec<T extends z.ZodType>(targetSchema: T) {
  return z.codec(z.string(), targetSchema, {
    decode: (jsonStr, payload) => {
      try {
        return JSON.parse(jsonStr);
      } catch (err) {
        payload.issues.push({
          code: "custom",
          input: jsonStr,
          message: err instanceof Error ? err.message : "Invalid JSON string",
        });
        return z.NEVER;
      }
    },
    encode: (value) => JSON.stringify(value),
  });
}

// Codec は大規模なオブジェクトスキーマの内部に直接ネスト可能!
const WebhookRowSchema = z.object({
  id: z.uuid(),
  createdAt: IsoDateCodec,
  payload: jsonCodec(
    z.object({
      event: z.enum(["user.created", "user.deleted"]),
      userId: z.int().positive(),
    })
  ),
});

// データベースやキューのレコード全体を1回の呼び出しでデコード:
const domainRow = z.decode(WebhookRowSchema, {
  id: "550e8400-e29b-41d4-a716-446655440000",
  createdAt: "2026-11-10T10:30:00.000Z",
  payload: '{"event":"user.created","userId":42}',
});
// domainRow.createdAt は `Date` インスタンス
// domainRow.payload は `{ event: "user.created", userId: 42 }`

// ドメインオブジェクトをそのままワイヤ形式へ逆エンコード:
const wireRow = z.encode(WebhookRowSchema, domainRow);
// wireRow.createdAt === "2026-11-10T10:30:00.000Z"
// wireRow.payload === '{"event":"user.created","userId":42}'

例3:URLSearchParams <-> 型付きクエリフィルタオブジェクト

const PaginationQueryCodec = z.codec(
  z.instanceof(URLSearchParams),
  z.object({
    page: z.int().min(1),
    limit: z.int().min(1).max(100),
    q: z.string().optional(),
  }),
  {
    decode: (params) => ({
      page: Number(params.get("page") ?? "1"),
      limit: Number(params.get("limit") ?? "20"),
      q: params.get("q") ?? undefined,
    }),
    encode: (query) => {
      const params = new URLSearchParams({
        page: String(query.page),
        limit: String(query.limit),
      });
      if (query.q !== undefined) params.set("q", query.q);
      return params;
    },
  }
);

const filters = z.decode(PaginationQueryCodec, new URLSearchParams("page=2&limit=50&q=zod"));
// => { page: 2, limit: 50, q: "zod" }

const searchParams = z.encode(PaginationQueryCodec, filters);
// => URLSearchParams { "page" => "2", "limit" => "50", "q" => "zod" }

組み込み z.stringbool() Codec と z.invertCodec()

  • z.stringbool(): 環境変数などの文字列表現("true", "1", "yes", "on", "y", "enabled" -> true、および "false", "0", "no", "off", "n", "disabled" -> false)と boolean を双方向変換する組み込みの ZodCodec<ZodString, ZodBoolean> です。
  • z.invertCodec(codec): 既存の ZodCodec<In, Out> の入力・出力スキーマおよび decode / encode 関数を入れ替えた逆方向Codec ZodCodec<Out, In> を生成します。

続いて 6. エラー整形と49言語i18nロケール に進み、Zod v4で刷新されたエラー整形ユーティリティ(z.prettifyError、z.treeifyError、z.flattenError)と49言語の国際化ロケールシステムを学びましょう。