Agent Skills: Zod 4 for TypeScript

>

UncategorizedID: leynos/agent-helper-scripts/zod4-typescript

Install this agent skill to your local

pnpm dlx add-skill https://github.com/leynos/agent-helper-scripts/tree/HEAD/skills/zod4-typescript

Skill Files

Browse the full folder contents for zod4-typescript.

Download Skill

Loading file tree…

skills/zod4-typescript/SKILL.md

Skill Metadata

Name
zod4-typescript
Description
>

Zod 4 for TypeScript

Zod 4 is the current stable release (latest: 4.3.x). It ships three packages from a single zod install: zod (classic), zod/mini (tree-shakable), and zod/v4/core (for library authors). TypeScript ≥5.5 and strict: true in tsconfig are hard requirements.

npm install zod@^4.0.0

Import the classic API throughout unless bundle size constraints demand zod/mini:

import * as z from "zod";

Navigating this skill

§1–§4 cover daily usage: schemas, parsing, error handling, type inference. §5 covers the Zod 3 → 4 migration traps — read this if porting existing code. §6–§8 cover powerful but less frequently needed features:

  • §6 Codecs — bidirectional serialization (API boundaries, date handling)
  • §7 Metadata, registries, and JSON Schema — OpenAPI generation, schema documentation
  • §8 Advanced patterns — recursive types, template literals, discriminated unions, z.xor, branded types, zod/mini, library authoring via zod/v4/core

For codec recipes, JSON Schema options, and zod/mini API mappings, see the references/ directory.

§1 Defining schemas

Prefer top-level format constructors over the deprecated method chain. This is both more tree-shakable and the direction of the API going forward (method equivalents will be removed in Zod 5).

// ✅ Zod 4 idiomatic
z.email()
z.uuidv4()
z.url()
z.ipv4()
z.iso.datetime()

// ❌ Deprecated — still works, will be removed in next major
z.string().email()
z.string().uuid()

Primitives and literals

All standard primitives: z.string(), z.number(), z.bigint(), z.boolean(), z.date(), z.null(), z.undefined(), z.void(), z.symbol(), z.never(), z.any(), z.unknown(). Note: z.literal() no longer accepts symbols.

z.literal() now accepts arrays for multi-value literals:

const HttpOk = z.literal([200, 201, 204]); // 200 | 201 | 204

Numeric formats

Fixed-width types with built-in range constraints:

z.int()      // safe integers only
z.int32()    // [-2^31, 2^31-1]
z.uint32()   // [0, 2^32-1]
z.float32()  // single-precision range
z.float64()  // double-precision range
z.int64()    // ZodBigInt — exceeds safe integer range
z.uint64()   // ZodBigInt

Objects

const User = z.object({
  name: z.string(),
  age: z.int(),
  email: z.email(),
});

type User = z.infer<typeof User>;

For strict (reject unknown keys) or loose (pass-through unknown keys) objects, use the top-level constructors rather than the deprecated .strict() / .passthrough() methods:

z.strictObject({ name: z.string() });   // rejects unrecognized keys
z.looseObject({ name: z.string() });    // passes through unrecognized keys
z.object({ name: z.string() });         // strips unrecognized keys (default)

Extending and composing objects

Use .extend() or shape spread. .merge() is deprecated.

const WithName = Base.extend({ name: z.string() });
// best tsc performance — use shape spread:
const WithName2 = z.object({ ...Base.shape, name: z.string() });
// .safeExtend() — preserves refinements and enforces extends constraint (4.1+)
const WithAge = Base.safeExtend({ age: z.int() });

⚠ Zod 3 trap: .extend() on a refined schema now throws if you overwrite existing properties. Use .safeExtend() to add new properties preserving refinements, or rebuild from .shape.

Enums

z.nativeEnum() is deprecated. z.enum() now handles both string arrays and TypeScript enums:

const Status = z.enum(["active", "inactive", "suspended"]);

// TypeScript enum (not recommended, but supported)
enum Direction { Up = "UP", Down = "DOWN" }
const Dir = z.enum(Direction);

Access values via .enum (.Enum and .Values are removed):

Status.enum.active; // "active"

Records

z.record() now requires two arguments (key schema, value schema). The single-argument form is removed.

z.record(z.string(), z.number()); // Record<string, number>

When the key schema is a z.enum(), Zod 4 exhaustively checks all enum members exist as keys. For partial records, use z.partialRecord(). For pass-through of non-matching keys, use z.looseRecord().

Arrays and tuples

z.array(z.string()).min(1).max(10);

// nonempty — ⚠ Zod 4 infers string[], not [string, ...string[]]
z.array(z.string()).nonempty();

// For the old tuple-style nonempty, use z.tuple with rest:
z.tuple([z.string()], z.string()); // [string, ...string[]]

File validation

z.file().min(10_000).max(1_000_000).mime(["image/png", "image/jpeg"]);

§2 Parsing and safe parsing

const result = User.safeParse(untrustedInput);
if (result.success) {
  result.data; // fully typed User
} else {
  result.error; // ZodError
}

// Throwing variant
const user = User.parse(untrustedInput);

Async variants (parseAsync, safeParseAsync) exist for schemas with async refinements or transforms.

§3 Error handling

Pretty printing

const err = User.safeParse(bad).error!;
console.log(z.prettifyError(err));
// ✖ Invalid input: expected string, received number
//   → at name

Structured error trees

.format() and .flatten() are deprecated. Use z.treeifyError():

const tree = z.treeifyError(err);
// tree.name?.errors  → string[]
// tree.age?.errors   → string[]

Customizing error messages

A single unified error parameter replaces message, invalid_type_error, required_error, and errorMap:

z.string().min(5, { error: "Too short" });

// Function form — replaces errorMap, invalid_type_error, required_error
z.string({
  error: (issue) =>
    issue.input === undefined ? "Required" : "Expected a string",
});

// Returning undefined yields to the next error map in the chain
z.string().min(5, {
  error: (issue) => issue.code === "too_small" ? `Need >${issue.minimum} chars` : undefined,
});

Internationalization

z.config(z.locales.en()); // or z.locales.de(), z.locales.ja(), etc.

Issue types

Zod 4 consolidated issue types. The base interface remains stable:

interface $ZodIssueBase {
  readonly code?: string;
  readonly input?: unknown;
  readonly path: PropertyKey[];
  readonly message: string;
}

Notable merges: invalid_enum_value/invalid_literalinvalid_value; invalid_date/not_finiteinvalid_type. Infinities always rejected by z.number().

§4 Type inference

type UserInput = z.input<typeof User>;   // type before transforms
type UserOutput = z.output<typeof User>;  // type after transforms (= z.infer)
type User = z.infer<typeof User>;         // alias for z.output

⚠ Zod 3 trap: z.any() and z.unknown() properties in objects are no longer optional in the inferred type. { a: z.any() } infers { a: any }, not { a?: any }.

§5 Migration traps (Zod 3 → 4)

This section is a concise checklist. For the full migration guide, see https://zod.dev/v4/changelog. A community codemod zod-v3-to-v4 is available.

High impact

  1. messageerror in all refinement / check options. message still works but is deprecated.
  2. invalid_type_error / required_error — dropped entirely. Use error function form.
  3. errorMap — renamed to error. Can now return a plain string or undefined.
  4. .format() / .flatten() — deprecated. Use z.treeifyError().
  5. .merge() — deprecated. Use .extend(other.shape) or shape spread.
  6. z.nativeEnum() — deprecated. z.enum() handles TS enums directly.
  7. z.record(valueSchema) — single-arg form removed. Always pass key + value schemas.
  8. z.string().email() etc — deprecated. Use z.email(), z.url(), z.uuidv4().
  9. .strict() / .passthrough() — deprecated. Use z.strictObject() / z.looseObject().
  10. z.function() — no longer a schema. New factory API with input/ output params.

Semantic changes (silent breakage risk)

  1. z.number() rejects Infinity/-Infinity.
  2. .int() rejects unsafe integers (outside Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER).
  3. .default() now short-circuits. The default value must match the output type, not the input type. For pre-parse defaults, use .prefault().
  4. Defaults inside optional fields are applied: z.string().default("x").optional() yields "x" when the key is missing, not undefined.
  5. z.unknown() / z.any() object properties are required in the inferred type.
  6. .nonempty() on arrays infers T[], not [T, ...T[]].
  7. Refinements via type predicates no longer narrow in .refine() (restored in 4.3 — see §8).
  8. Error map precedence changed: schema-level error now takes priority over parse-time error.
  9. z.record() with enum keys is now exhaustive — all enum members must be present. Use z.partialRecord() for the old behaviour.
  10. .pick() / .omit() on refined schemas now throws (4.3+) — previously silently dropped refinements.

§6 Codecs (4.1+)

Codecs solve the serialization boundary problem. Where .transform() is unidirectional, a codec defines both directions, enabling .encode() to round-trip data.

const isoDate = z.codec(z.iso.datetime(), z.date(), {
  decode: (iso) => new Date(iso),
  encode: (date) => date.toISOString(),
});

isoDate.decode("2025-01-15T10:30:00.000Z"); // → Date
isoDate.encode(new Date());                  // → "2025-01-15T..."
isoDate.parse("2025-01-15T10:30:00.000Z");  // → Date (same as decode)

Key constraint: .transform() is incompatible with .encode() — calling .encode() on a pipeline containing .transform() throws. Refactor to codecs when you need round-tripping.

Codecs compose via .pipe():

const JsonParams = jsonCodec.pipe(z.object({ name: z.string(), age: z.number() }));
JsonParams.decode('{"name":"Alice","age":30}'); // → { name: "Alice", age: 30 }
JsonParams.encode({ name: "Bob", age: 25 });    // → JSON string

Most non-transforming schemas behave identically under .decode() and .encode(). The behaviour diverges for codecs, .default() (short-circuits on decode, rejects undefined on encode), .catch() (decode only), and z.stringbool() (a codec internally).

For a full library of codec implementations (JSON, hex, base64, URL, epoch, etc.), see references/codec-recipes.md.

§7 Metadata, registries, and JSON Schema

Registries

Store strongly typed metadata outside the schema itself, in a registry:

const apiRegistry = z.registry<{
  title: string;
  description: string;
  deprecated?: boolean;
}>();

const Email = z.email();
apiRegistry.add(Email, { title: "Email", description: "User email address" });
apiRegistry.get(Email); // → { title: "Email", ... }

Global registry and .meta()

z.string().meta({
  id: "user_name",
  title: "Username",
  description: "Unique handle",
  examples: ["alice42"],
});

// Shorthand for description only (.describe() still works but .meta() is preferred)
z.string().meta({ description: "A username" });

JSON Schema conversion

// Zod → JSON Schema
const jsonSchema = z.toJSONSchema(User);

// JSON Schema → Zod (experimental, 4.3+)
const zodSchema = z.fromJSONSchema({
  type: "object",
  properties: { name: { type: "string" } },
  required: ["name"],
});

z.toJSONSchema() pulls metadata from z.globalRegistry automatically. For full options (including $refStrategy, effectStrategy, named definitions), see references/json-schema-options.md.

z.fromJSONSchema() supports JSON Schema draft-2020-12, draft-7, draft-4, and OpenAPI 3.0. Consider it experimental — no guarantee of round-trip soundness through toJSONSchema → fromJSONSchema.

§8 Advanced patterns

Recursive types (native — no casts needed)

Use getter syntax. Unlike Zod 3's z.lazy() pattern, no type assertion required, and the result is a full ZodObject with .pick(), .partial(), etc.

const Category = z.object({
  name: z.string(),
  get subcategories() { return z.array(Category); },
});
type Category = z.infer<typeof Category>;
// { name: string; subcategories: Category[] }

Mutual recursion works identically via cross-referencing getters.

Template literal types

Represent TypeScript template literal types with validated parsing. String format schemas (e.g. z.email()) work inside — their internal regexes are concatenated. Custom refinements are not enforced.

const CssValue = z.templateLiteral([z.number(), z.enum(["px", "em", "rem", "%"])]);
CssValue.parse("16px"); // ✅

Discriminated unions (upgraded)

Now support union/pipe discriminators and compose — a discriminated union can be a member of another. See the z.discriminatedUnion() docs for examples.

z.xor() (4.3+)

Exclusive union — requires exactly one match. Produces oneOf in JSON Schema.

const schema = z.xor([
  z.object({ type: z.literal("user"), name: z.string() }),
  z.object({ type: z.literal("admin"), role: z.string() }),
]);

Type predicate refinements (restored in 4.3)

const isString = z.unknown().refine((v): v is string => typeof v === "string");
type T = z.output<typeof isString>; // string

Branded types (enhanced in 4.3)

z.string().brand<"UserId">();           // output only (default)
z.string().brand<"UserId", "in">();     // input only
z.string().brand<"UserId", "inout">(); // both

.apply() (4.3+) — factor out reusable check pipelines

const clamp = <T extends z.ZodNumber>(s: T) => s.min(0).max(100);
z.number().apply(clamp).nullable();

z.stringbool() — env var booleans

Truthy: "true", "1", "yes", "on", "y", "enabled". Falsy: "false", "0", "no", "off", "n", "disabled". Customizable via { truthy: [...], falsy: [...] }.

Zod Mini and library authoring

Zod Mini (zod/mini): functional, tree-shakable API (~1.88 KB gzipped). See references/zod-mini-mapping.md for the full method→function mapping.

For library authors: depend on zod/v4/core for compatibility with both Classic and Mini. It exports $ZodType, $ZodCheck, $ZodError and top-level parsing functions. The "zod/v4/core" subpath is a permanent, stable permalink.

Best practices

  1. Top-level format constructors (z.email(), not z.string().email()).
  2. z.strictObject() at API boundaries to catch unexpected fields.
  3. Codecs for serialization boundaries — API responses, form data, env vars.
  4. .meta() and registries to drive JSON Schema / OpenAPI generation.
  5. Shape spread over .extend() for best tsc performance in large schemas.
  6. .safeParse() over .parse() in user-facing paths — avoid try/catch for expected validation failures.
  7. z.prettifyError() for humans; z.treeifyError() for programmatic access.
  8. z.int() / z.int32() over z.number().int() for clarity and built-in ranges.
  9. z.xor() when exactly one variant must match — correct oneOf in JSON Schema.
  10. z.stringbool() for env vars instead of hand-rolled coercion.

Forward compatibility

  • Method-form string formats (.email(), .uuid()) will be removed in Zod 5.
  • .strict(), .passthrough(), .merge(), .describe() — deprecated but retained.
  • z.fromJSONSchema() is experimental and may change in minor releases.
  • zod/v4/core is the stable contract for library authors — prefer it over internals.