All free skills / Other majors

zod 4.x

Validate boundaries with explicit failure handling, transformed types and async parsing.

For
zod@4.5.4
Verified
2026-10-10, 11 examples executed
Skill package
@modootoday/pmcp-zod-schema-validation@4.0.1

Use this skill for free

No PMCP login or subscription is required. Download the original instructions, or install the content-only archive with your project's package manager.

Download SKILL.md · Download package archive

npm install --save-dev https://pmcp.build/skills/zod-schema-validation/4/package.tgz

PMCP discovers the installed package's instructions. Connect your runtime after installation.

Archive integritysha512-MPurOH1a1V8lAWV/rKEoczdO0yUmX3hEEV9+x3m8x9nTNyHIVulfncfkAwIVh+Iapu7HukvPodHfneW54bu5jA==

Complete instructions

The original seven standalone examples were verified against zod@4.5.4 on 2026-09-07. Additional development fixtures cover transform input/output types and async parsing; see the catalog for their latest executed verification.

zod

Parse at the boundary, then trust the value. The point of a schema is that everything past it is typed because it was checked, not because it was cast.

When to reach for it

Reach for this when data arrives from outside the program and its shape is a claim rather than a fact: a request body, a config file, an environment variable, a third-party response. Do not reach for it to re-check a value your own code just produced.

Parse or branch, not both

parse throws and safeParse returns a result. Pick by whether the caller has something to do about a failure. A route that must answer 400 branches; a boot path that cannot continue throws.

import { z } from "zod";
import assert from "node:assert/strict";

const Config = z.object({ port: z.number(), host: z.string() });

// safeParse when the caller answers for the failure.
const result = Config.safeParse({ port: "8080", host: "localhost" });
assert.equal(result.success, false);

// parse when there is no answer but to stop.
assert.deepEqual(Config.parse({ port: 8080, host: "localhost" }), {
  port: 8080,
  host: "localhost",
});

Unknown keys are dropped, not rejected

An object schema ignores keys it does not declare and returns only what it declared. This is the behaviour most often assumed wrong in both directions: it does not fail, and it does not pass the extra key through.

import { z } from "zod";
import assert from "node:assert/strict";

const User = z.object({ id: z.string() });
const parsed = User.parse({ id: "u1", role: "admin" });

// The undeclared key is gone. Reading parsed.role would be undefined, so an
// authorization check written against it silently never fires.
assert.deepEqual(parsed, { id: "u1" });
assert.equal("role" in parsed, false);

If the extra key should be an error, say so with .strict(); if it should survive, say so with .passthrough(). Both are explicit, and the default is neither.

import { z } from "zod";
import assert from "node:assert/strict";

const Strict = z.object({ id: z.string() }).strict();
assert.equal(Strict.safeParse({ id: "u1", extra: 1 }).success, false);

const Open = z.object({ id: z.string() }).passthrough();
assert.deepEqual(Open.parse({ id: "u1", extra: 1 }), { id: "u1", extra: 1 });

Read the failure, do not stringify it

A ZodError carries issues, and each issue names the path that failed. That path is what a person needs; the stack trace is not.

import { z } from "zod";
import assert from "node:assert/strict";

const Order = z.object({ item: z.object({ qty: z.number() }) });
const outcome = Order.safeParse({ item: { qty: "two" } });

assert.equal(outcome.success, false);
if (!outcome.success) {
  const [issue] = outcome.error.issues;
  assert.deepEqual(issue.path, ["item", "qty"]);
  assert.equal(issue.code, "invalid_type");
  // `expected` is what the schema wanted, which is what the message should say.
  assert.equal(issue.expected, "number");
}

For a whole-form answer, z.treeifyError groups messages by field, which maps onto a form without any reshaping of your own.

import { z } from "zod";
import assert from "node:assert/strict";

const Signup = z.object({ email: z.string(), age: z.number() });
const outcome = Signup.safeParse({ email: 1, age: "old" });

assert.equal(outcome.success, false);
if (!outcome.success) {
  const tree = z.treeifyError(outcome.error);
  assert.ok(tree.properties?.email?.errors.length);
  assert.ok(tree.properties?.age?.errors.length);
}

Let the schema be the type

Infer the type from the schema instead of declaring both. Two declarations drift, and the one that drifts is the one the compiler is not checking.

import { z } from "zod";
import assert from "node:assert/strict";

const Session = z.object({ userId: z.string(), expiresAt: z.number() });
type Session = z.infer<typeof Session>;

const session: Session = Session.parse({ userId: "u1", expiresAt: 1 });
assert.equal(session.userId, "u1");

Coerce only where the wire is untyped

z.coerce converts before checking, which is right for query strings and environment variables and wrong for a JSON body that already has types. Coercing a JSON number field hides a producer sending strings.

import { z } from "zod";
import assert from "node:assert/strict";

const Query = z.object({ page: z.coerce.number() });
assert.deepEqual(Query.parse({ page: "2" }), { page: 2 });

const Body = z.object({ page: z.number() });
assert.equal(Body.safeParse({ page: "2" }).success, false);

Transform input and output deliberately

A transform can change the output type. Use z.input<typeof schema> for callers and z.output<typeof schema> for validated results; z.infer describes the output. Keep transport coercion at the boundary and validate before transforming.

import assert from "node:assert/strict";
import { z } from "zod";

const Count = z.string().regex(/^\d+$/).transform(Number);
type CountInput = z.input<typeof Count>;
type CountOutput = z.output<typeof Count>;

const input: CountInput = "12";
const output: CountOutput = Count.parse(input);
assert.equal(output, 12);
assert.equal(Count.safeParse("twelve").success, false);

An async refinement or transform requires parseAsync or safeParseAsync. A synchronous parse is not an equivalent replacement. Check the success branch before reading data; map issues into the caller's existing error contract.

Development checks and sources

Run the project's existing typecheck and test runner after changing a boundary. Verify accepted data, rejected data, unknown-key handling and transformed output. Preserve the existing error response format.

Consult Zod schemas and transforms for the pinned major. Features added after the catalog's exact verified version need their own verification.

Advanced boundaries

Custom error maps and codec composition each have enough surface to be their own skill. Discriminated unions are worth reading about before modelling a tagged payload as a plain union, because the error a plain union produces names every branch it tried.