From bb393acb4cbc2e36ca839fe6b0d2a5bb63550d35 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 30 Apr 2026 02:50:46 +0900 Subject: [PATCH 1/5] feat(zod): add use-zod skill plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-written workflow skill for Zod schema validation, mirroring the mastra plugin pattern from #161. Single trigger-heavy SKILL.md plus three references (versions, schemas, parsing-and-errors) covering both Zod v3 and v4 with inline migration callouts. Key v3 → v4 differences surfaced: - err.format() / err.flatten() → z.treeifyError() / z.flattenError() - separate { message, errorMap } → unified { error } param - z.lazy + ZodType annotation → property getters for recursion - .superRefine() (deprecated) → .check() - new in v4: z.codec, z.prettifyError, .overwrite, zod/mini No MCP server (zod has no official docs MCP server). Skills-only plugin with skills: ['./skills/']. --- plugins/zod/.claude-plugin/plugin.json | 21 ++ plugins/zod/skills/use-zod/SKILL.md | 123 ++++++ .../use-zod/references/parsing-and-errors.md | 243 ++++++++++++ .../zod/skills/use-zod/references/schemas.md | 350 ++++++++++++++++++ .../zod/skills/use-zod/references/versions.md | 135 +++++++ 5 files changed, 872 insertions(+) create mode 100644 plugins/zod/.claude-plugin/plugin.json create mode 100644 plugins/zod/skills/use-zod/SKILL.md create mode 100644 plugins/zod/skills/use-zod/references/parsing-and-errors.md create mode 100644 plugins/zod/skills/use-zod/references/schemas.md create mode 100644 plugins/zod/skills/use-zod/references/versions.md diff --git a/plugins/zod/.claude-plugin/plugin.json b/plugins/zod/.claude-plugin/plugin.json new file mode 100644 index 0000000..a6a398b --- /dev/null +++ b/plugins/zod/.claude-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "zod", + "version": "1.0.0", + "description": "TypeScript-first schema validation with static type inference - version-aware skill for Zod v3 and v4", + "author": { + "name": "Colin McDonnell", + "url": "https://github.com/colinhacks" + }, + "homepage": "https://zod.dev", + "repository": "https://github.com/colinhacks/zod", + "license": "MIT", + "keywords": [ + "zod", + "validation", + "schema", + "typescript" + ], + "skills": [ + "./skills/" + ] +} diff --git a/plugins/zod/skills/use-zod/SKILL.md b/plugins/zod/skills/use-zod/SKILL.md new file mode 100644 index 0000000..38e758f --- /dev/null +++ b/plugins/zod/skills/use-zod/SKILL.md @@ -0,0 +1,123 @@ +--- +name: use-zod +description: 'Answer questions about the Zod schema validation library and help build schemas, parsers, refinements, transforms, codecs, and error formatters. Use when developers: (1) ask about Zod APIs like `z.object`, `z.string`, `z.array`, `z.union`, `z.discriminatedUnion`, `parse`, `safeParse`, `z.infer`; (2) define request/response/form schemas in TypeScript; (3) handle `ZodError` or customize error messages; (4) migrate between Zod v3 and v4 (entry-point split, `formatError` → `treeifyError`/`prettifyError`, unified `error` param replacing `message`/`errorMap`). Triggers on: "zod", "z.object", "z.string", "z.array", "z.union", "z.infer", "z.input", "z.output", "ZodError", "$ZodError", "safeParse", "parseAsync", "z.codec", "treeifyError", "prettifyError", "flattenError", "discriminatedUnion", "zod/v4", "zod/v3", "zod/mini", "z.coerce", "superRefine".' +--- + +## Prerequisites + +Before writing Zod code, verify the installed version and entry points in the current project: + +```bash +# installed version (drives everything below) +cat node_modules/zod/package.json 2>/dev/null | jq -r .version + +# subpath exports — confirms which import paths resolve +cat node_modules/zod/package.json 2>/dev/null | jq '.exports | keys' +``` + +If `zod` is missing, install only what the task requires: + +```bash +# v4 (current default since zod@4.0.0) +pnpm add zod # or: bun add zod / npm i zod / yarn add zod + +# pin to v3 only when the project explicitly requires it +pnpm add zod@^3 +``` + +Detect the package manager from the lockfile (`pnpm-lock.yaml` → pnpm, `bun.lockb` → bun, `package-lock.json` → npm, `yarn.lock` → yarn). + +## Critical: Do Not Trust Internal Knowledge + +Zod 4 (released 2026) was a major rewrite. Many APIs that were canonical in v3 are now deprecated, renamed, or removed. Examples that are commonly miswritten from training data: + +- `err.format()` / `err.flatten()` (v3 instance methods) — in v4 these are top-level functions: `z.treeifyError(err)` / `z.flattenError(err)`. `z.formatError()` exists but is **deprecated** in favour of `z.treeifyError()`. +- `z.string({ message, errorMap })` (v3) — v4 unifies these into a single `error` param: `z.string({ error: "Bad!" })` or `z.string({ error: (iss) => "..." })`. +- `.superRefine()` — deprecated in v4. Use `.check()` instead. +- `error instanceof z.ZodError` — works for the regular `zod` package; for `zod/mini` use `error instanceof z.core.$ZodError` (the parent class). +- Codecs (`z.codec(...)`) — only exist in `zod@4.1+`. Do not suggest them on v3 or earlier 4.x. + +When working with Zod: + +1. Resolve the installed version first (`node_modules/zod/package.json`). +2. If unsure whether an API exists in the installed version, grep the bundled `.d.ts`: `rg -n "treeifyError|formatError" node_modules/zod/dist`. +3. Check upstream docs **at the matching version pin** (links below in [`references/versions.md`](references/versions.md)) — not at `main`, which tracks the latest release. +4. Run typecheck after every change. Zod schemas are heavily inferred and silent type drift is rare. +5. Never invent method names. If the user references an API you don't recognise, look it up before answering. +6. Surface deprecations to the user instead of silently emitting either pattern. + +If documentation cannot be found locally or remotely to back an answer, say so explicitly. + +## Version detection — branch v4 vs v3 paths + +```bash +node -e "const v=require('zod/package.json').version; console.log(v.startsWith('4.')?'v4':v.startsWith('3.')?'v3':v)" +``` + +| Detected | Default import | Errors API | Refinement API | Codecs | +| --- | --- | --- | --- | --- | +| v4 (≥4.0.0) | `import * as z from "zod"` | `z.treeifyError`, `z.prettifyError`, `z.flattenError` | `.refine()`, `.check()` | `z.codec()` (4.1+) | +| v3 (≥3.0, <4.0) | `import { z } from "zod"` | `err.format()`, `err.flatten()` | `.refine()`, `.superRefine()` | — | +| v3.25.x bridge | `import * as z from "zod/v4"` opt-in to v4 alongside v3 | per the chosen path | per the chosen path | — | + +`zod@3.25` shipped both v3 (default) and v4 (under `zod/v4`) in the same package to ease migration. From `zod@4.0.0` onward, the root export is v4 and `zod/v3` is the back-compat path. See [`references/versions.md`](references/versions.md). + +## Entry points (v4) + +| Import | Use when | +| --- | --- | +| `import * as z from "zod"` | Default. Standard ergonomic API with chainable methods (`z.string().min(5)`). | +| `import * as z from "zod/mini"` | Bundle-size-sensitive frontend code. Functional API: `z.string().check(z.minLength(5))`. ~64% smaller for trivial schemas. | +| `import * as z from "zod/v3"` | Legacy code on v3 that you can't migrate yet, while consuming a `zod@4` package. | +| `import * as z from "zod/v4-mini"` (within `zod@3.25`) | Forward-compat path for projects pinned to v3 that want to start adopting Mini. | + +`zod/mini` and `zod` interop: schemas from one cannot be passed to the other's parse functions. Pick one per project unless you have a deliberate reason to mix. + +## Authoring schemas + +Concise cookbook of common patterns, each tagged with the version it applies to: [`references/schemas.md`](references/schemas.md). + +Rules of thumb: + +- **`z.object` is non-strict by default** — extra keys are stripped. Use `z.strictObject({...})` to reject extra keys, or `.passthrough()` (v3) / `.loose()` (v4) to preserve them. +- **`.optional()` vs `.nullable()` vs `.nullish()`** — `optional` allows `undefined`, `nullable` allows `null`, `nullish` allows both. +- **Always export the type** with `z.infer`. Use `z.input` and `z.output` separately when the schema transforms (input ≠ output, e.g. `z.string().transform(s => s.length)`). +- **Discriminated unions need a literal discriminator** — `z.discriminatedUnion("type", [...])` is dramatically faster and produces better error messages than `z.union(...)` when shapes share a tag field. +- **Recursive schemas** use different patterns per version: v4 uses object property **getters** (`get children() { return z.array(Self); }`); v3 uses `z.lazy(() => Schema)` plus an explicit `z.ZodType` annotation. See [`references/schemas.md`](references/schemas.md#recursive-schemas). + +## Parsing & error handling + +Concise reference: [`references/parsing-and-errors.md`](references/parsing-and-errors.md). + +Quick map: + +- `parse(input)` — throws on invalid; returns typed deep clone. +- `safeParse(input)` — returns `{ success: true, data } | { success: false, error: ZodError }`. +- `parseAsync` / `safeParseAsync` — required when the schema contains async refinements, transforms, or codecs. +- `try { ... } catch (e) { if (e instanceof z.ZodError) e.issues }` — every error has an `.issues` array of `{ code, path, message, expected?, ... }`. + +The `formatError` → `treeifyError` rename is the single most common source of broken v3-era examples. Surface it whenever rewriting v3 error-handling code. + +## When typecheck or runtime fails + +Before searching source code, check the most common Zod failure modes: + +1. **`Invalid input: expected X`** with no `path` — top-level shape mismatch; verify the schema matches the expected outer type. +2. **`Cannot read property 'parseAsync' of undefined`** — usually an import-path mismatch (`zod` vs `zod/mini`); methods on Mini schemas live on top-level functions instead. +3. **`Type 'ZodError' is not assignable to type '$ZodError'`** — mixing `zod` and `zod/mini` schemas in the same code path. +4. **Async refinement throws "Synchronous parsing not supported"** — switch the call site from `parse` to `parseAsync` (or `safeParse` to `safeParseAsync`). +5. **`.superRefine` flagged as deprecated** (v4) — replace with `.check()` per [`references/parsing-and-errors.md`](references/parsing-and-errors.md). +6. **Custom error not surfacing** — confirm you're using the unified `error` param (v4) and not the legacy `message`/`errorMap` shape (v3). + +If the symptom is not listed: + +```bash +# resolve the error string inside installed source +rg -n "error string fragment" node_modules/zod/dist +``` + +## References + +- [`references/versions.md`](references/versions.md) — entry points, version detection, v3 ↔ v4 API rename cheatsheet, links to upstream docs at version pins (v4.3.6, v3.25.76) +- [`references/schemas.md`](references/schemas.md) — primitives, objects, arrays, unions, discriminated unions, recursion, refinements, transforms, codecs (v4.1+) — each example tagged `// v4`, `// v3`, or `// both` +- [`references/parsing-and-errors.md`](references/parsing-and-errors.md) — `parse` vs `safeParse` vs `parseAsync`, `ZodError` shape, `treeifyError`/`prettifyError`/`flattenError` (v4), `format`/`flatten` (v3), error customization diff --git a/plugins/zod/skills/use-zod/references/parsing-and-errors.md b/plugins/zod/skills/use-zod/references/parsing-and-errors.md new file mode 100644 index 0000000..181a8cf --- /dev/null +++ b/plugins/zod/skills/use-zod/references/parsing-and-errors.md @@ -0,0 +1,243 @@ +# Parsing & Error Handling + +Purpose: choose the right parse method, read `ZodError` correctly, format errors for users, and customize messages — with v3 ↔ v4 differences flagged inline. + +## Parse methods + +```ts +schema.parse(input); // throws ZodError on failure; returns typed deep clone +schema.safeParse(input); // returns { success: true, data } | { success: false, error } +schema.parseAsync(input); // required when schema has async refinements/transforms/codecs +schema.safeParseAsync(input); // safe variant of the async path +``` + +In v4, codec schemas also expose: + +```ts +schema.decode(input); // strongly-typed input; same runtime behavior as .parse +schema.encode(value); // reverse direction (output → input) +schema.safeDecode(input); +schema.safeEncode(value); +schema.decodeAsync(input); +schema.encodeAsync(value); +``` + +Pick `parse` when an exception path is acceptable (e.g. server controllers with framework-level error handlers). Pick `safeParse` for code that must branch on success without exceptions (e.g. form handlers, RPC). + +### When to use the async variants + +If **any** node in the schema graph is async, you must use `parseAsync` / `safeParseAsync`: + +- `.refine(async (val) => ...)` — async refinement +- `.transform(async (val) => ...)` — async transform +- `z.codec(..., { decode: async (...) => ..., encode: async (...) => ... })` — async codec + +Calling sync `.parse()` on a schema with async checks throws `Synchronous parsing not supported`. The fix is the call site, not the schema. + +## Reading `ZodError` + +```ts +import * as z from "zod"; // v4 + +const schema = z.strictObject({ + username: z.string(), + favoriteNumbers: z.array(z.number()), +}); + +const result = schema.safeParse({ + username: 1234, + favoriteNumbers: [1234, "4567"], + extraKey: 1234, +}); + +if (!result.success) { + result.error.issues; + // [ + // { expected: "string", code: "invalid_type", path: ["username"], + // message: "Invalid input: expected string, received number" }, + // { expected: "number", code: "invalid_type", path: ["favoriteNumbers", 1], + // message: "Invalid input: expected number, received string" }, + // { code: "unrecognized_keys", keys: ["extraKey"], path: [], + // message: 'Unrecognized key: "extraKey"' }, + // ] +} +``` + +`result.error` is a `z.ZodError` (subclass of `z.core.$ZodError`). Each issue has at least `code`, `path`, and `message`; additional fields depend on the issue code (`expected`, `received`, `keys`, `minimum`, `maximum`, etc.). + +> **`zod/mini` users**: parse errors are `z.core.$ZodError`, not `z.ZodError`. Adjust your `instanceof` checks. + +## Formatting errors + +### v4 — top-level functions + +```ts +import * as z from "zod"; + +const result = schema.safeParse(input); +if (!result.success) { + z.treeifyError(result.error); // nested object mirroring schema shape + z.flattenError(result.error); // { formErrors, fieldErrors } — flat one-level shape + z.prettifyError(result.error); // human-readable string with bullets and paths +} +``` + +`z.treeifyError(result.error)` for the example above returns: + +```ts +{ + errors: ["Unrecognized key: \"extraKey\""], + properties: { + username: { errors: ["Invalid input: expected string, received number"] }, + favoriteNumbers: { + errors: [], + items: [ + undefined, + { errors: ["Invalid input: expected number, received string"] }, + ], + }, + }, +} +``` + +Access nested errors with optional chaining: `tree.properties?.username?.errors`. + +`z.prettifyError(result.error)` returns: + +``` +✖ Unrecognized key: "extraKey" +✖ Invalid input: expected string, received number + → at username +✖ Invalid input: expected number, received string + → at favoriteNumbers[1] +``` + +`z.formatError(err)` still exists in v4 but is **deprecated** — prefer `z.treeifyError` (the shape changed slightly: `errors`/`properties`/`items` instead of v3's `_errors` underscore convention). + +### v3 — instance methods + +```ts +// v3 +const result = schema.safeParse(input); +if (!result.success) { + result.error.format(); // { _errors, [field]: { _errors } } + result.error.flatten(); // { formErrors, fieldErrors } +} +``` + +The v3 `format()` shape uses `_errors` as the leaf array on every node: + +```ts +{ + _errors: ["Unrecognized key: \"extraKey\""], + username: { _errors: ["Invalid input: expected string, received number"] }, + favoriteNumbers: { + _errors: [], + "1": { _errors: ["Invalid input: expected number, received string"] }, + } +} +``` + +When porting v3 form-handling code to v4, the `_errors` → `errors`/`items` rename is the most common breakage. `flattenError` shape (`{ formErrors, fieldErrors }`) is unchanged. + +## Customizing error messages + +### v4 — unified `error` param + +A single `error` option replaces v3's separate `message` and `errorMap`. It accepts a string or a function. + +```ts +// static string +z.string({ error: "Bad!" }); +z.string().min(5, { error: "Too short!" }); +z.uuid({ error: "Bad UUID!" }); +z.array(z.string(), { error: "Not an array!" }); + +// shorthand: positional string +z.string("Bad!"); +z.string().min(5, "Too short!"); + +// function form (the v4 "error map") +z.string({ error: (iss) => iss.input === undefined ? "Required" : "Invalid" }); + +// inspect issue context +z.string().min(5, { + error: (iss) => { + iss.code; // issue code + iss.input; // the input value + iss.path; // the path within the parent schema + iss.minimum; // available because we're on .min() + iss.inclusive; + return `Must be ≥ ${iss.minimum} chars`; + }, +}); + +// per-parse override +schema.parse(input, { error: (iss) => "Custom message" }); + +// global override +z.config({ customError: (iss) => iss.path.length === 0 ? "Top-level error" : undefined }); +``` + +Returning `undefined` from the function falls through to the next map in Zod's precedence chain (schema-level → parse-level → global → default). Use this to selectively override only certain issue codes. + +### v3 — separate `message` / `errorMap` + +```ts +// v3 +z.string({ required_error: "Required", invalid_type_error: "Bad!" }); +z.string().min(5, { message: "Too short!" }); + +// v3 errorMap function +z.string({ + errorMap: (iss, ctx) => { + if (iss.code === "invalid_type") return { message: "Bad!" }; + return { message: ctx.defaultError }; + }, +}); + +// v3 per-parse +schema.parse(input, { errorMap }); + +// v3 global +z.setErrorMap(myErrorMap); +``` + +v3 → v4 cookbook: + +| v3 | v4 | +| --- | --- | +| `z.string({ required_error, invalid_type_error })` | `z.string({ error: (iss) => iss.input === undefined ? "Required" : "Bad" })` | +| `z.string({ message: "..." })` | `z.string({ error: "..." })` | +| `z.string({ errorMap: fn })` | `z.string({ error: fn })` (signature simplified to one arg `iss`) | +| `z.setErrorMap(fn)` | `z.config({ customError: fn })` | +| `(iss, ctx) => ({ message: ctx.defaultError })` | `(iss) => undefined` (returning `undefined` defers to default) | + +## Common failure modes + +1. **`Synchronous parsing not supported`** — schema has async checks; switch caller from `parse` to `parseAsync`. +2. **`error.format` is not a function** — code on v4 still using v3 instance method; replace with `z.treeifyError(error)`. +3. **`z.formatError` is deprecated** — switch to `z.treeifyError`. +4. **`error instanceof z.ZodError === false`** in `zod/mini` — Mini parse errors are `z.core.$ZodError`. Use `error instanceof z.core.$ZodError` or import `ZodError` from the regular package. +5. **Custom message ignored** — passing v3 `{ message: ... }` shape to a v4 schema. Use `error: ...`. +6. **`required_error` / `invalid_type_error` not recognized** in v4 — replace with a function `error: (iss) => iss.input === undefined ? "Required" : "Bad"`. +7. **`ctx.defaultError` undefined in error map** — v4 error maps return `undefined` to defer; there is no `ctx.defaultError`. +8. **Strict object rejecting valid data with extra fields** — using `z.strictObject` (v4) or `.strict()` (v3). Switch to `.loose()` (v4) / `.passthrough()` (v3) or remove the strictness. + +## Issue codes (high-level) + +Both versions emit the same conceptual codes, with minor renames in v4. Common ones: + +`invalid_type`, `unrecognized_keys`, `invalid_union`, `invalid_value` (v4) / `invalid_literal` (v3), `too_small`, `too_big`, `not_multiple_of`, `invalid_string` (v3), `custom`. + +In v4, string format violations use `invalid_format` (e.g. failed `z.email`). In v3 they use `invalid_string` with a `validation` field. + + diff --git a/plugins/zod/skills/use-zod/references/schemas.md b/plugins/zod/skills/use-zod/references/schemas.md new file mode 100644 index 0000000..f61af03 --- /dev/null +++ b/plugins/zod/skills/use-zod/references/schemas.md @@ -0,0 +1,350 @@ +# Zod Schemas Cookbook + +Purpose: copy-pasteable patterns for the most common schema shapes, each tagged with the version it applies to. Verify the chosen import path matches `node_modules/zod/package.json`. + +Every example assumes: + +```ts +import * as z from "zod"; // v4 +// or: import { z } from "zod"; // v3 +``` + +## Primitives & coercion + +```ts +// both +z.string(); +z.number(); +z.bigint(); +z.boolean(); +z.date(); + +// coercion (both, identical API in v3 and v4) +z.coerce.string(); // String(input) +z.coerce.number(); // Number(input) +z.coerce.boolean(); // Boolean(input) — note: any truthy value → true (incl. "false") +z.coerce.date(); // new Date(input) +``` + +When parsing form data or query params, prefer `z.coerce.*` over manual `.transform`. The coerced input type is `unknown`; pin it explicitly when needed: + +```ts +const Age = z.coerce.number(); // input: number, output: number +``` + +## Strings, formats & checks + +```ts +// v4 — methods +z.string().min(5).max(20).regex(/^[a-z]+$/); +z.email(); +z.uuid(); +z.url(); +z.iso.datetime(); +z.iso.date(); + +// v4 Mini — functions via .check() +z.string().check(z.minLength(5), z.maxLength(20), z.regex(/^[a-z]+$/)); + +// v3 — same as regular v4 but z.email() etc. live as methods on z.string() +z.string().email().min(5); +``` + +The `z.email()` / `z.uuid()` / `z.url()` top-level builders are v4. In v3, write `z.string().email()`. + +## Object schemas + +```ts +// both +const User = z.object({ + id: z.string().uuid(), // v4: z.uuid() + name: z.string().min(1), + email: z.string().email(), // v4: z.email() + age: z.number().int().nonnegative().optional(), +}); + +type User = z.infer; +``` + +### Strict, loose, catchall + +```ts +// v4 +z.strictObject({ id: z.string() }); // throws on unknown keys +z.looseObject({ id: z.string() }); // preserves unknown keys +z.object({ id: z.string() }).catchall(z.string()); // unknown values must satisfy z.string() + +// v3 +z.object({ id: z.string() }).strict(); +z.object({ id: z.string() }).passthrough(); +z.object({ id: z.string() }).catchall(z.string()); +``` + +### Pick, omit, partial, required, extend + +```ts +// both +const User = z.object({ id: z.string(), name: z.string(), email: z.string() }); + +User.pick({ id: true, name: true }); +User.omit({ email: true }); +User.partial(); // all fields optional +User.partial({ email: true }); // only email optional +User.required(); // all fields required (drops .optional()) + +// v4: .extend() works the same, but the underlying generics were redesigned +// to avoid tsc instantiation explosions on chained .extend().omit() chains +const Admin = User.extend({ role: z.literal("admin") }); + +// alternative: spread syntax (clearer about strictness) +const Admin2 = z.object({ ...User.shape, role: z.literal("admin") }); +``` + +### Optional vs nullable vs nullish + +```ts +z.string().optional() // T | undefined +z.string().nullable() // T | null +z.string().nullish() // T | null | undefined +z.string().default("") // input: T | undefined, output: T + +// v4 also has +z.string().nonoptional() +``` + +## Arrays, tuples, records + +```ts +// arrays — both +z.array(z.string()); +z.string().array(); +z.array(z.string()).min(1).max(10).nonempty(); + +// tuples — both +z.tuple([z.string(), z.number()]); +z.tuple([z.string()]).rest(z.boolean()); // [string, ...boolean[]] + +// records — both +z.record(z.string(), z.number()); // { [k: string]: number } + +// v4 only +z.partialRecord(z.string(), z.number()); // values may be undefined +z.looseRecord(z.string(), z.number()); // tolerant of extra keys +``` + +## Unions & discriminated unions + +```ts +// regular union — checks each option in order (slow for many options) +z.union([z.string(), z.number()]); +z.string().or(z.number()); // shorthand + +// discriminated union — picks the right option via a literal field +const Result = z.discriminatedUnion("status", [ + z.object({ status: z.literal("success"), data: z.string() }), + z.object({ status: z.literal("failed"), error: z.string() }), +]); + +// v4 supports nesting: an inner discriminatedUnion can itself be an option +const Errors = z.discriminatedUnion("code", [ + z.object({ status: z.literal("failed"), code: z.literal(400), msg: z.string() }), + z.object({ status: z.literal("failed"), code: z.literal(500), msg: z.string() }), +]); +const Outer = z.discriminatedUnion("status", [ + z.object({ status: z.literal("success"), data: z.string() }), + Errors, +]); +``` + +Discriminator must be a literal-bearing key (`z.literal`, `z.enum`, `z.null`, `z.undefined`). Never use `z.string()` as the discriminator. + +## Recursive schemas + +> **The recursive pattern changed between v3 and v4.** v4 uses **getters**; v3 uses `z.lazy` with an explicit annotation. + +### v4 — getter-based + +```ts +// v4 +const Category = z.object({ + name: z.string(), + get subcategories() { + return z.array(Category); + }, +}); + +type Category = z.infer; +// { name: string; subcategories: Category[] } +``` + +If TypeScript reports `'subcategories' implicitly has return type 'any'`, add an explicit return annotation: + +```ts +const Activity = z.object({ + name: z.string(), + get subactivities(): z.ZodNullable> { + return z.nullable(z.array(Activity)); + }, +}); +``` + +Mutually recursive types are supported the same way: + +```ts +const User = z.object({ + email: z.email(), + get posts() { return z.array(Post); }, +}); +const Post = z.object({ + title: z.string(), + get author() { return User; }, +}); +``` + +### v3 — `z.lazy` with annotation + +```ts +// v3 +type Category = { name: string; subcategories: Category[] }; + +const Category: z.ZodType = z.lazy(() => + z.object({ + name: z.string(), + subcategories: z.array(Category), + }), +); +``` + +The `z.ZodType` annotation is required in v3 to break the recursion in TypeScript's inference. + +> Passing cyclical data (an object that references itself) into a recursive schema causes an infinite loop in **both** versions. Cycle-detect upstream of `parse()`. + +## Refinements & transforms + +### Simple refinement (both) + +```ts +z.string().refine((val) => val.includes("@"), "Must contain @"); +z.string().refine((val) => val.includes("@"), { + message: "Must contain @", + path: ["email"], +}); +``` + +### Multi-issue refinement + +```ts +// v4 — preferred: .check() +const UniqueStringArray = z.array(z.string()).check((ctx) => { + if (ctx.value.length > 3) { + ctx.issues.push({ + code: "too_big", + maximum: 3, + origin: "array", + inclusive: true, + message: "Too many items", + input: ctx.value, + }); + } + if (ctx.value.length !== new Set(ctx.value).size) { + ctx.issues.push({ + code: "custom", + message: "No duplicates allowed", + input: ctx.value, + }); + } +}); + +// v3 — .superRefine() (also still works in v4 but is deprecated) +const UniqueStringArrayV3 = z.array(z.string()).superRefine((val, ctx) => { + if (val.length !== new Set(val).size) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "No duplicates" }); + } +}); +``` + +### Async refinement + +Any async refinement forces async parsing. The schema's `parse` will throw `Synchronous parsing not supported`; switch to `parseAsync` / `safeParseAsync`. + +```ts +// both +const Username = z.string().refine( + async (val) => !(await usernameTaken(val)), + "Username already taken", +); + +await Username.parseAsync("alice"); +``` + +### Transforms + +```ts +// both — input ≠ output +const Length = z.string().transform((val) => val.length); +type Input = z.input; // string +type Output = z.output; // number +``` + +### `.overwrite()` (v4 only) + +`.transform` changes the inferred output type. `.overwrite` preserves the type — use it when you want to normalize a value without a type change: + +```ts +// v4 +const TrimmedString = z.string().overwrite((val) => val.trim()); +type T = z.infer; // string (unchanged) +``` + +## Codecs (v4.1+) + +Bidirectional transformation between two schemas. Useful at network boundaries (e.g. ISO date string ↔ `Date` object). + +```ts +// v4.1+ +const stringToDate = z.codec( + z.iso.datetime(), + z.date(), + { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString(), + }, +); + +stringToDate.decode("2024-01-15T10:30:00.000Z"); // => Date +stringToDate.encode(new Date()); // => string +stringToDate.parse("2024-01-15T10:30:00.000Z"); // identical to .decode at runtime; types differ +``` + +`.parse` accepts `unknown`; `.decode` and `.encode` are strongly typed at the input end. Codecs do not exist in v3 — do not suggest them on a v3 install. + +## Type inference patterns + +```ts +const User = z.object({ id: z.string(), age: z.coerce.number() }); + +type User = z.infer; // { id: string; age: number } +type UserIn = z.input; // { id: string; age: unknown } +type UserOut = z.output; // same as z.infer + +// brand a primitive into a nominal type +const UserId = z.string().uuid().brand<"UserId">(); +type UserId = z.infer; // string & { [BRAND]: "UserId" } +``` + +## What to avoid + +- `z.lazy(() => ...)` in v4 — use a getter instead. `z.lazy` still exists for non-object recursion but the getter pattern is the canonical solution. +- `.superRefine()` in v4 — deprecated; use `.check()`. +- `z.string({ message, errorMap })` separate options — use unified `error` (v4) or stick with v3 syntax. +- `err.format()` on v4 — use `z.treeifyError(err)`. +- Mixing `zod` and `zod/mini` schemas in the same parse path — pick one. + + diff --git a/plugins/zod/skills/use-zod/references/versions.md b/plugins/zod/skills/use-zod/references/versions.md new file mode 100644 index 0000000..e35b18c --- /dev/null +++ b/plugins/zod/skills/use-zod/references/versions.md @@ -0,0 +1,135 @@ +# Zod Versions & Entry Points + +Purpose: pick the right import path for the installed version, and translate v3 APIs to their v4 equivalents (or vice versa). + +## Detection + +```bash +node -e "const v=require('zod/package.json').version; console.log(v)" +``` + +Or read the file directly: + +```bash +cat node_modules/zod/package.json | jq '{ version, exports: (.exports | keys) }' +``` + +## Entry points by installed version + +### Installed `zod@4.x` + +| Import | Resolves to | Use when | +| --- | --- | --- | +| `import * as z from "zod"` | v4 (default since 4.0.0) | Default. Most projects. | +| `import * as z from "zod/mini"` | v4 Mini (functional, tree-shakable) | Bundle-size-sensitive frontend code. | +| `import * as z from "zod/v3"` | v3 back-compat | Legacy modules in a project that has otherwise migrated to v4. | +| `import * as z from "zod/v4"` | v4 (explicit) | Same as default. Use when both v3 and v4 imports coexist in the same file for clarity. | + +### Installed `zod@3.25.x` (the bridge release) + +| Import | Resolves to | +| --- | --- | +| `import { z } from "zod"` or `import * as z from "zod"` | v3 (default while pinned to 3.x) | +| `import * as z from "zod/v4"` | v4 (opt-in, alongside v3) | +| `import * as z from "zod/v4-mini"` | v4 Mini (opt-in) | + +`zod@3.25` shipped both v3 (default root export) and v4 (under `zod/v4`) in a single package to ease migration. From `zod@4.0.0` onward the root export flipped to v4. + +### Installed `zod@<3.25` + +Only the v3 default export exists: + +```ts +import { z } from "zod"; +// or +import * as z from "zod"; +``` + +There is no `zod/v4` subpath. To use v4, upgrade to `zod@^4`. + +## v3 ↔ v4 API cheatsheet + +Surface the relevant row whenever rewriting code between versions. + +### Error formatting & customization + +| Topic | v3 | v4 | +| --- | --- | --- | +| Tree of issues | `err.format()` (instance method) | `z.treeifyError(err)` (top-level fn). `z.formatError(err)` exists but is deprecated. | +| Flat object | `err.flatten()` → `{ formErrors, fieldErrors }` | `z.flattenError(err)` → `{ formErrors, fieldErrors }` (same shape). | +| Pretty string | — (DIY) | `z.prettifyError(err)` | +| Error class | `z.ZodError` | `z.ZodError` (extends `z.core.$ZodError`). For `zod/mini`, parse errors are `z.core.$ZodError`. | +| Schema-level custom message | `z.string({ message: "..." })` | `z.string({ error: "..." })` | +| Schema-level error map | `z.string({ errorMap: (iss, ctx) => ... })` | `z.string({ error: (iss) => "..." })` | +| Per-parse error map | `schema.parse(input, { errorMap })` | `schema.parse(input, { error })` | +| Global error map | `z.setErrorMap(fn)` | `z.config({ customError: fn })` | +| Async issue ctx in v3 | `(iss, ctx) => ctx.defaultError` | `(iss) => undefined` falls through to next map in precedence chain | + +### Refinements & checks + +| Topic | v3 | v4 | +| --- | --- | --- | +| Simple refinement | `.refine(fn, "msg")` | `.refine(fn, "msg")` (unchanged) | +| Async refinement | `.refine(async fn, "msg")` + `parseAsync` | `.refine(async fn, "msg")` + `parseAsync` (unchanged) | +| Multi-issue refinement | `.superRefine((val, ctx) => { ctx.addIssue(...) })` | `.check(({ value, issues }) => { issues.push(...) })`. `.superRefine` still works but is deprecated. | +| Replace value during validation | `.transform(fn)` (changes inferred type) | `.transform(fn)` or `.overwrite(fn)` (overwrite preserves the type) | + +### Schema methods + +| Topic | v3 | v4 | +| --- | --- | --- | +| Reject extra object keys | `z.object({...}).strict()` | `z.strictObject({...})` or `z.object({...}).strict()` | +| Preserve extra object keys | `z.object({...}).passthrough()` | `z.object({...}).loose()` (also: `z.looseObject({...})`) | +| Pick / omit / partial / required | `.pick`, `.omit`, `.partial`, `.required` | same — but `ZodObject` generics were redesigned, so chained `.extend().omit()` is much cheaper for `tsc` | +| Recursive schemas | `const Tree: z.ZodType = z.lazy(() => z.object({...}))` (`z.lazy` + explicit annotation) | Getter pattern: `z.object({ name: z.string(), get children() { return z.array(Self); } })` — annotation only needed for inference edge cases | +| Coercion | `z.coerce.number()` | `z.coerce.number()` (unchanged) | +| Branded types | `.brand<"Id">()` | `.brand<"Id">()` (unchanged) | + +### Parsing + +| Topic | v3 | v4 | +| --- | --- | --- | +| Sync parse | `.parse`, `.safeParse` | `.parse`, `.safeParse` | +| Async parse | `.parseAsync`, `.safeParseAsync` | `.parseAsync`, `.safeParseAsync` | +| Encode/decode | — | `.decode`, `.encode`, `.safeDecode`, `.safeEncode` (with `z.codec`, 4.1+) | + +### New in v4 (no v3 equivalent) + +- `z.codec(input, output, { decode, encode })` — bidirectional transformation between two schemas. Introduced in `zod@4.1`. +- `z.prettifyError(err)` — human-readable error string. +- `z.treeifyError(err)` — replaces deprecated `z.formatError(err)`. +- `.overwrite(fn)` — like `.transform` but preserves the inferred type. +- `.check(...)` (chainable on schemas; replaces `.superRefine`). +- `z.config({ customError })` — global error map registration. +- `zod/mini` — functional, tree-shakable variant. + +### Removed or restricted in v4 + +- `z.string({ message, errorMap })` separate params — collapsed into `error`. +- `z.setErrorMap` — replaced by `z.config({ customError })`. +- `nonstrict()` (rare; v3 had it as the inverse of `strict()`) — gone; use `.loose()` instead. + +## Upstream documentation pins + +Always link the user to docs at the version that matches their installed package, not `main`. + +- v4.3.6 (latest as of 2026-01-22): https://github.com/colinhacks/zod/tree/v4.3.6/packages/docs/content + - Live site: https://zod.dev (tracks the latest published v4) +- v3.25.76 (last v3 release, includes `zod/v4` bridge): https://github.com/colinhacks/zod/tree/v3.25.76/packages/docs/content +- v3 archive (pre-bridge, frozen): https://github.com/colinhacks/zod/tree/main/packages/docs-v3 + +## When in doubt + +1. `cat node_modules/zod/package.json | jq .version` — read the actual version. +2. `cat node_modules/zod/package.json | jq '.exports | keys'` — confirm available subpaths. +3. `rg -n "" node_modules/zod/dist` — verify the symbol exists in the installed build before suggesting it. + + From 5892e1dec11f8f323cee7a038ca6b17f20f1bfeb Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 30 Apr 2026 02:50:54 +0900 Subject: [PATCH 2/5] chore: register zod plugin in marketplace and release-please - .claude-plugin/marketplace.json: add zod entry pointing to ./plugins/zod - release-please-config.json: add plugins/zod with simple release-type and extra-files jsonpath $.version for plugin.json bumps - README.md: append Zod section after Portless mirroring existing entries --- .claude-plugin/marketplace.json | 8 ++++++++ README.md | 5 +++++ release-please-config.json | 11 +++++++++++ 3 files changed, 24 insertions(+) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 880cef5..12f9715 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -600,6 +600,14 @@ "keywords": ["portless", "localhost", "dev-server", "proxy"], "tags": ["tooling", "dev-server"], "source": "./plugins/portless" + }, + { + "name": "zod", + "description": "TypeScript-first schema validation with static type inference - version-aware skill for Zod v3 and v4", + "category": "development", + "keywords": ["zod", "validation", "schema", "typescript"], + "tags": ["validation", "typescript"], + "source": "./plugins/zod" } ] } diff --git a/README.md b/README.md index 3633600..e9e9a47 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,11 @@ Replace port numbers with stable, named local URLs. For humans and agents. **Install:** `/plugin install portless@pleaseai` | **Source:** [plugins/portless](https://github.com/pleaseai/claude-code-plugins/tree/main/plugins/portless) +#### Zod +TypeScript-first schema validation with static type inference — version-aware skill covering Zod v3 and v4 differences (entry points, error formatting, refinements). + +**Install:** `/plugin install zod@pleaseai` | **Source:** [plugins/zod](https://github.com/pleaseai/claude-code-plugins/tree/main/plugins/zod) + ## Quick Start The fastest way to get started — install the marketplace and let the plugin recommender auto-detect what you need: diff --git a/release-please-config.json b/release-please-config.json index c4f1f17..ee0c2dd 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -493,6 +493,17 @@ "jsonpath": "$.version" } ] + }, + "plugins/zod": { + "release-type": "simple", + "component": "zod", + "extra-files": [ + { + "type": "json", + "path": ".claude-plugin/plugin.json", + "jsonpath": "$.version" + } + ] } }, "release-type": "node", From e61a56a5965831cbc4fa8914239700a45062b60d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 30 Apr 2026 03:31:52 +0900 Subject: [PATCH 3/5] feat(zod): use ask CLI as primary source for version-accurate docs Mirrors the use-better-auth pattern: lead with `ask src zod` / `ask docs zod` to read the lockfile-pinned source (with comments and tests), demoting node_modules/zod/dist to a fallback for environments where ask isn't installed. - New 'Finding Documentation' section in SKILL.md showing the SRC=$(ask src zod) idiom for reading docs, verifying symbols, and finding canonical examples in tests - versions.md 'When in doubt' switched to ask-first verification with explicit @version pinning examples for v4.3.6 and v3.25.76 - 'When typecheck or runtime fails' debug snippet now greps $(ask src zod)/packages/zod/src instead of node_modules/zod/dist --- plugins/zod/skills/use-zod/SKILL.md | 81 ++++++++++++++++--- .../zod/skills/use-zod/references/versions.md | 24 +++++- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/plugins/zod/skills/use-zod/SKILL.md b/plugins/zod/skills/use-zod/SKILL.md index 38e758f..2a48381 100644 --- a/plugins/zod/skills/use-zod/SKILL.md +++ b/plugins/zod/skills/use-zod/SKILL.md @@ -5,13 +5,15 @@ description: 'Answer questions about the Zod schema validation library and help ## Prerequisites -Before writing Zod code, verify the installed version and entry points in the current project: +Verify the `ask` CLI is available (`which ask`). It is the primary tool for reading the exact version installed in this project — it resolves the version from the lockfile, fetches docs/source once, and caches them at `~/.ask/`. If `ask` is not installed, fall back to `node_modules/zod/` and the official site at https://zod.dev (which tracks the latest published v4, not necessarily the installed version). + +Before writing Zod code, verify the installed version and entry points: ```bash -# installed version (drives everything below) +# installed version — drives everything below cat node_modules/zod/package.json 2>/dev/null | jq -r .version -# subpath exports — confirms which import paths resolve +# subpath exports — confirms which import paths resolve (zod, zod/mini, zod/v3, zod/v4) cat node_modules/zod/package.json 2>/dev/null | jq '.exports | keys' ``` @@ -39,15 +41,72 @@ Zod 4 (released 2026) was a major rewrite. Many APIs that were canonical in v3 a When working with Zod: -1. Resolve the installed version first (`node_modules/zod/package.json`). -2. If unsure whether an API exists in the installed version, grep the bundled `.d.ts`: `rg -n "treeifyError|formatError" node_modules/zod/dist`. -3. Check upstream docs **at the matching version pin** (links below in [`references/versions.md`](references/versions.md)) — not at `main`, which tracks the latest release. +1. Resolve the installed version against the local checkout with `ask` (see [Finding Documentation](#finding-documentation) below). +2. Verify every API name, method signature, and option shape against the source or bundled `.d.ts` before generating code. Never invent method names. +3. Cross-reference upstream docs **at the matching version pin** ([`references/versions.md`](references/versions.md) has the v4.3.6 / v3.25.76 links) — not `main`, which tracks the latest release. 4. Run typecheck after every change. Zod schemas are heavily inferred and silent type drift is rare. -5. Never invent method names. If the user references an API you don't recognise, look it up before answering. -6. Surface deprecations to the user instead of silently emitting either pattern. +5. Surface deprecations to the user instead of silently emitting either pattern (e.g. `.superRefine` works but is deprecated in v4 — say so). If documentation cannot be found locally or remotely to back an answer, say so explicitly. +## Finding Documentation + +Resolve the source checkout and docs directory once with `ask`; reuse the paths across reads: + +```bash +SRC=$(ask src zod) # checkout root +DOCS=$(ask docs zod | head -n1) # candidate docs dir +``` + +Both pin to the version in the project's lockfile. To inspect a specific version regardless of the project, append `@version`: + +```bash +SRC_V4=$(ask src zod@4.3.6) +SRC_V3=$(ask src zod@3.25.76) +``` + +### Read the README and docs content + +```bash +cat "$DOCS/README.md" +ls "$SRC/packages/docs/content" # v4 docs source (mdx) +cat "$SRC/packages/docs/content/api.mdx" # full API reference +cat "$SRC/packages/docs/content/error-formatting.mdx" +cat "$SRC/packages/docs/content/error-customization.mdx" +cat "$SRC/packages/docs/content/codecs.mdx" # v4.1+ only +``` + +### Verify a symbol exists in the installed version + +```bash +# top-level functions (v4): treeifyError, prettifyError, flattenError, codec, config +rg -n "^export (function|const) (treeifyError|prettifyError|flattenError|codec|config)\\b" "$SRC/packages/zod/src" + +# instance methods on schemas +rg -n "(\\.refine|\\.check|\\.superRefine|\\.overwrite|\\.transform|\\.parseAsync)\\b" "$SRC/packages/zod/src" + +# subpath exports +cat "$SRC/packages/zod/package.json" | jq '.exports | keys' +``` + +### Find canonical example shapes (tests are the most reliable source) + +```bash +fd -e test.ts . "$SRC/packages/zod/tests" +rg -n "discriminatedUnion|z\\.codec|treeifyError" "$SRC/packages/zod/tests" +``` + +### Fallback when `ask` is unavailable + +```bash +SRC=./node_modules/zod +ls $SRC/dist +rg "treeifyError" $SRC/dist # confirm v4 helpers shipped in this build +cat $SRC/package.json | jq .version +``` + +Use https://zod.dev only to cross-reference — it always tracks the latest published v4. + ## Version detection — branch v4 vs v3 paths ```bash @@ -109,11 +168,11 @@ Before searching source code, check the most common Zod failure modes: 5. **`.superRefine` flagged as deprecated** (v4) — replace with `.check()` per [`references/parsing-and-errors.md`](references/parsing-and-errors.md). 6. **Custom error not surfacing** — confirm you're using the unified `error` param (v4) and not the legacy `message`/`errorMap` shape (v3). -If the symptom is not listed: +If the symptom is not listed, resolve the source and grep the error string: ```bash -# resolve the error string inside installed source -rg -n "error string fragment" node_modules/zod/dist +rg -n "error string fragment" "$(ask src zod)/packages/zod/src" +# fallback: rg -n "error string fragment" node_modules/zod/dist ``` ## References diff --git a/plugins/zod/skills/use-zod/references/versions.md b/plugins/zod/skills/use-zod/references/versions.md index e35b18c..db5e5d9 100644 --- a/plugins/zod/skills/use-zod/references/versions.md +++ b/plugins/zod/skills/use-zod/references/versions.md @@ -120,9 +120,27 @@ Always link the user to docs at the version that matches their installed package ## When in doubt -1. `cat node_modules/zod/package.json | jq .version` — read the actual version. -2. `cat node_modules/zod/package.json | jq '.exports | keys'` — confirm available subpaths. -3. `rg -n "" node_modules/zod/dist` — verify the symbol exists in the installed build before suggesting it. +Prefer `ask` over `node_modules/` — it resolves the project's lockfile-pinned version and gives you full source (with comments and tests), not just the compiled output. + +```bash +SRC=$(ask src zod) +cat "$SRC/packages/zod/package.json" | jq .version # actual installed version +cat "$SRC/packages/zod/package.json" | jq '.exports | keys' # available subpaths +rg -n "" "$SRC/packages/zod/src" # verify symbol exists +``` + +Pin to a specific version regardless of the project: + +```bash +ask src zod@4.3.6 # latest v4 +ask src zod@3.25.76 # latest v3 (with v4 bridge) +``` + +Fallback when `ask` is unavailable: + +1. `cat node_modules/zod/package.json | jq .version` +2. `cat node_modules/zod/package.json | jq '.exports | keys'` +3. `rg -n "" node_modules/zod/dist`