Zod solves a problem TypeScript alone cannot: your types are erased at runtime, so an API response or user form typed as User might contain arbitrary garbage at the boundary. Zod lets you declare the expected shape once as a schema, validate incoming data against it, and derive the TypeScript type automatically via z.infer<typeof schema> — no separate interface to keep in sync, no runtime/compile-time mismatch.
The most important design decision in Zod is .parse() vs .safeParse(). .parse() throws a ZodError on failure, which suits internal data you control. .safeParse() returns { success: true, data } or { success: false, error }, which is safer at external boundaries (API bodies, query parameters, environment variables, form inputs).
What this skill enables
With this skill loaded, ask Claude to:
- Write a schema for any data shape, then export the TypeScript type with
z.infer<typeof schema>
- Add cross-field validation using
.refine() (e.g., confirm-password must equal password) or .superRefine() for multiple co-located error paths
- Use
z.discriminatedUnion('kind', [...]) when object shape depends on a type tag field
- Wire Zod into React Hook Form via
zodResolver, into tRPC as input validators, or into Express/Fastify as request-body validators
- Transform data during parsing with
.transform() — for example, coercing ISO strings into Date objects
Common prompts
User registration schema
Prompt: "Create a Zod schema for user registration: email, password (min 8 chars, requires a digit and special character), optional phone, and a confirmPassword that must match password."
Claude will build a reusable passwordSchema, compose the outer object schema with .refine() for the cross-field confirm check, and export the inferred UserRegistration type.
REST API request validation
Prompt: "Build Zod schemas for a paginated list endpoint: query params (page, limit, optional search string), and a JSON body with nested filters. Use safeParse and return HTTP 400 on validation failure."
Claude will write separate schemas for query and body, call schema.safeParse(req.query), and shape the 400 error response from error.format().
Environment variable validation at startup
Prompt: "Validate process.env at boot with Zod. Required: DATABASE_URL (URL), JWT_SECRET (non-empty string), PORT (coerced to number). Optional: LOG_LEVEL defaulting to 'info'."
Claude will use z.string().url(), z.coerce.number(), and .default(), calling .parse(process.env) so the app fails fast on misconfiguration before any requests are served.
Discriminated union for API response parsing
Prompt: "My API returns { status: 'ok', data: User } or { status: 'error', code: number, message: string }. Write a Zod discriminated union and handle both branches with narrowed types."
Claude will use z.discriminatedUnion('status', [...]) — faster and more precise than z.union() when a shared tag field identifies the variant.
Tips for reliable schemas
- Infer types from schemas:
type User = z.infer<typeof userSchema> — delete any manually maintained interface that duplicates the schema
- Prefer
.safeParse() at boundaries: API routes, form submissions, env vars — anywhere data enters from the outside
- Custom messages inline:
.min(8, 'Must be at least 8 characters') or { message: '...' } — clearer than Zod's generic defaults
- Async refinements require
.parseAsync(): if your .refine() callback is async, swap to safeParseAsync() or the result will be a Promise, not a value
- Compose, don't copy:
.extend() adds fields to an existing schema; .pick() and .omit() create focused subsets without duplication
- Recursive schemas: use
z.lazy(() => nodeSchema) to handle tree structures that would otherwise cause circular reference errors
Troubleshooting
Type inference not working — ensure TypeScript 5.0+ and strict: true in tsconfig.json. Use z.infer<typeof schema> rather than a manually written interface; mismatches disappear when the type is derived rather than declared.
Custom error messages not showing — pass the message as the second argument to the validator: .min(8, 'Too short') or { message: 'Too short' }. Call error.format() to see the nested structure per field, or error.flatten() for a flat { fieldErrors, formErrors } map.
Async validation silently ignored — .refine(async fn) and .transform(async fn) require the async parse path (parseAsync / safeParseAsync). Calling synchronous .parse() with an async refinement resolves the promise inside the schema as a truthy value rather than awaiting it.
Union errors are confusing — switch from z.union() to z.discriminatedUnion('typeField', [...]) when objects share a common tag field; error messages narrow to the correct branch instead of listing all variants.
Optional vs nullable confusion — .optional() accepts undefined; .nullable() accepts null; .nullish() accepts both. They are not interchangeable. Use .default(value) to supply a fallback when the field is absent rather than making it optional and handling undefined downstream.
Learn More