5. Refinements, Pipelines & Bidirectional Codecs

Schema validation often requires more than structural type checking: passwords must match confirmation fields, strings need in-place normalization, raw form inputs must be piped into typed domain models, and network payloads must be serialized both ways between wire formats (JSON strings, ISO 8601 timestamps, URLSearchParams) and rich JavaScript runtime objects (Date, Map, typed domain records).

Zod v4 unifies custom validation checks (.refine(), .superRefine(), .check()), type-preserving mutations (.overwrite()), unidirectional pipelines (.transform(), .pipe()), and first-class bidirectional codecs (z.codec(), z.decode(), z.encode()).

05_refinements_and_codecs_1
Zod v4 Bidirectional z.codec() Decode/Encode Architecture vs Unidirectional Pipelines
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, {...})",
    "Bidirectional 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(
    "Forward: z.decode(codec, wire)",
    "Validates In -> runs decode() -> validates 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(
    "Backward: z.encode(codec, domain)",
    "Validates Out -> runs encode() -> validates In",
    badge_text="ENC",
)
codec_cards.add(
    "z.stringbool() & z.invertCodec()",
    "Built-in env bool codec & direction inversion",
    badge_text="INV",
)
codec_cards.draw(l_xy, width=l_w, height=l_h)

right_panel = Panel(
    "Unidirectional Pipelines",
    "One-Way Transforms",
    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=[
        ["Pipeline API", "Direction & Type Effect"],
        [".overwrite(fn)", "In-place (Out == In)"],
        [".transform(fn)", "One-way (no encode)"],
        [".pipe(target)", "Output<A> -> Input<B>"],
        ["z.preprocess(fn, B)", "Coerce before B"],
        [".refine() / .check()", "Validate only (Out == In)"],
    ],
)

save()

5.1 Custom Validation: .refine(), .superRefine(), and .check()

When built-in format checks are not sufficient to express business invariants—such as cross-field comparisons, checksum verification, or asynchronous database uniqueness checks—Zod v4 provides three progressive levels of custom validation hooks.

05_refinements_and_codecs_2
Custom Validation Hierarchy and Issue Continuation Flow in Zod v4
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(
    "Custom Validation APIs",
    "Three Progressive Levels",
    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)",
    "Boolean predicate with custom error & path",
    badge_text="L1",
)
val_cards.add(
    ".superRefine((val, ctx) => ...)",
    "Emit multiple issues via ctx.addIssue({...})",
    badge_text="L2",
)
val_cards.add(
    ".check((ctx) => ...) (New in v4)",
    "Low-level hook pushing directly to 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(
    "Issue Execution Flow",
    "Payload & Continuation",
    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. Structural & Format Checks",
    "Validates base types & built-in constraints",
    badge_text="S1",
)
flow_cards.add(
    "2. Non-Fatal Continuation",
    "continue: true runs subsequent checks on failure",
    badge_text="S2",
)
flow_cards.add(
    "3. Nested Path Routing",
    "path: ['confirmPassword'] targets specific field",
    badge_text="S3",
)
flow_cards.draw(r_xy, width=r_w, height=r_h)

save()

1. Predicate Refinements with .refine()

.refine(predicate, params) accepts a synchronous or async predicate function (val) => boolean. If the predicate returns a falsy value, Zod emits a custom validation issue. If the predicate is a TypeScript type guard (val): val is SubType => ..., Zod automatically narrows the schema's output type!

import { z } from "zod";

// Simple predicate with a string error message
const EvenInt = z.int().refine((n) => n % 2 === 0, "Must be an even integer");

// Cross-field object validation with targeted `path` and dynamic `error`
const PasswordSignupSchema = z
  .object({
    email: z.email(),
    password: z.string().min(12),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: "Passwords do not match",
    path: ["confirmPassword"],
  });

2. Multi-Issue Validation with .superRefine()

When a single validation pass needs to emit multiple distinct issues (with different error messages or field paths), use .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: "endDate cannot be earlier than startDate",
        path: ["endDate"],
      });
      return;
    }

    const spanDays = (endMs - startMs) / (1000 * 60 * 60 * 24);
    if (spanDays > val.maxSpanDays) {
      ctx.addIssue({
        code: "custom",
        message: `Range (${spanDays}d) exceeds maxSpanDays (${val.maxSpanDays}d)`,
        path: ["endDate"],
      });
    }
  });

3. Low-Level .check() Hook (New in Zod v4)

Under the hood, every Zod v4 validation constraint is implemented via .check(). You can pass a custom function directly to .check((ctx) => ...) to inspect ctx.value and push structured issues directly into ctx.issues with fine-grained control over continue: true (allowing subsequent checks to continue executing):

const StrictSlug = z.string().check((ctx) => {
  if (ctx.value.startsWith("-") || ctx.value.endsWith("-")) {
    ctx.issues.push({
      code: "custom",
      input: ctx.value,
      message: "Slug cannot start or end with a hyphen",
      continue: true, // Keep running remaining checks
    });
  }
  if (ctx.value.includes("--")) {
    ctx.issues.push({
      code: "custom",
      input: ctx.value,
      message: "Slug cannot contain consecutive hyphens",
    });
  }
});

5.2 Type-Preserving Mutations with .overwrite() (New in Zod v4)

In Zod v3, calling .transform() always wrapped the schema in a ZodEffects instance, changing its class type and stripping schema-specific methods like .min() or .max().

Zod v4 introduces .overwrite(fn) for type-preserving in-place transformations ((x: Output) => Output). Because .overwrite() is implemented as a regular check rather than a wrapper type:

  1. It returns the exact same schema class (this), so you can keep chaining .min(), .max(), or object combinators after .overwrite().
  2. Indeed, built-in string normalizers like z.string().trim(), .toLowerCase(), .toUpperCase(), and .normalize() are all powered by .overwrite() in Zod v4!
// Normalize whitespace and strip leading '@' while remaining a `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 Unidirectional Transforms & Pipelines (.transform(), .pipe())

When a transformation changes the runtime type (for example, converting a string into a number[] or a domain object), use .transform() and chain downstream validation schemas with .pipe():

// 1. `.transform()` converts Output<A> into a new type
const CsvTokens = z
  .string()
  .trim()
  .transform((raw) => (raw === "" ? [] : raw.split(",").map((s) => Number(s.trim()))));
// Input type: string
// Output type: number[]

// 2. `.pipe()` feeds Output<A> into the Input of a target schema B
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]

Important: Unidirectional .transform() functions only define a forward conversion (input -> output). Attempting to call z.encode() on a schema containing an uninvertible .transform() will throw a runtime error. Whenever you need two-way serialization, use z.codec() instead!


5.4 Bidirectional Codecs (z.codec, z.decode, z.encode)

One of Zod v4's flagship architectural additions is z.codec(inputSchema, outputSchema, { decode, encode }). A codec pairs an input schema (representing the serialized/wire format, such as a JSON string or ISO timestamp) with an output schema (representing the parsed application/domain type, such as a Date or typed object), along with symmetric decode and encode functions.

Forward Decoding vs Backward Encoding

Operation Throwing Function Safe Result Function Direction & Type Signature
Forward (Decode) z.decode(schema, input) (or schema.decode(input)) z.safeDecode(schema, input) z.input<S> -> z.output<S> (Validates In, runs decode, validates Out)
Backward (Encode) z.encode(schema, output) (or schema.encode(output)) z.safeEncode(schema, output) z.output<S> -> z.input<S> (Validates Out, runs encode, validates In)
Untyped Parse z.parse(schema, unknownVal) z.safeParse(schema, unknownVal) unknown -> z.output<S> (Identical runtime path to decode, accepts unknown)

Async Support: Async codecs (async decode / async encode) are fully supported via z.decodeAsync(), z.encodeAsync(), z.safeDecodeAsync(), and z.safeEncodeAsync().

Example 1: ISO 8601 Datetime String <-> JavaScript Date

import { z } from "zod";

const IsoDateCodec = z.codec(
  z.iso.datetime(), // Wire format: ISO 8601 string ("2026-11-10T10:30:00Z")
  z.date(),         // Domain format: JavaScript Date instance
  {
    decode: (isoString) => new Date(isoString),
    encode: (dateObj) => dateObj.toISOString(),
  }
);

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

// 1. Decode wire string -> Date instance
const parsedDate = z.decode(IsoDateCodec, "2026-11-10T10:30:00.000Z");
// => Date(2026-11-10T10:30:00.000Z)

// 2. Encode Date instance -> ISO 8601 wire string
const serializedIso = z.encode(IsoDateCodec, parsedDate);
// => "2026-11-10T10:30:00.000Z"

Example 2: Generic JSON String <-> Typed Object Codec

You can create a reusable higher-order jsonCodec(schema) helper that decodes JSON strings into any validated Zod schema and encodes validated objects back into JSON strings—catching malformed JSON syntax cleanly via payload.issues.push(...):

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),
  });
}

// Nest codecs directly inside larger object schemas!
const WebhookRowSchema = z.object({
  id: z.uuid(),
  createdAt: IsoDateCodec,
  payload: jsonCodec(
    z.object({
      event: z.enum(["user.created", "user.deleted"]),
      userId: z.int().positive(),
    })
  ),
});

// Decode an entire database/queue row in one call:
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 is a `Date`
// domainRow.payload is `{ event: "user.created", userId: 42 }`

// Encode the mutated domain object right back to wire format:
const wireRow = z.encode(WebhookRowSchema, domainRow);
// wireRow.createdAt === "2026-11-10T10:30:00.000Z"
// wireRow.payload === '{"event":"user.created","userId":42}'

Example 3: URLSearchParams <-> Typed Query Filter Object

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" }

Built-in z.stringbool() Codec & z.invertCodec()

  • z.stringbool(): Built-in ZodCodec<ZodString, ZodBoolean> that decodes case-insensitive truthy strings ("true", "1", "yes", "on", "y", "enabled") to true and falsy strings ("false", "0", "no", "off", "n", "disabled") to false, and encodes booleans back to "true" / "false".
  • z.invertCodec(codec): Flips a ZodCodec<In, Out> into a ZodCodec<Out, In> by swapping its input/output schemas and decode/encode functions.

Next, proceed to 6. Error Formatting & 49 i18n Locales to explore Zod v4's streamlined error customization API (z.prettifyError, z.treeifyError, z.flattenError) and built-in internationalization across 49 languages.